diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index b6ba18dc..2b7e06c9 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -70,6 +70,12 @@ jobs: rm GhosttyKit.xcframework.tar.gz test -d GhosttyKit.xcframework + - name: Install zig + run: | + if ! command -v zig >/dev/null 2>&1; then + brew install zig + fi + - name: Clean DerivedData run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78daa1bf..cabe59bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,12 @@ jobs: run: | ./scripts/download-prebuilt-ghosttykit.sh + - name: Install zig + run: | + if ! command -v zig >/dev/null 2>&1; then + brew install zig + fi + - name: Clean DerivedData run: | # Remove stale build cache to avoid incremental build errors @@ -175,6 +181,12 @@ jobs: fi fi + - name: Run bundled Ghostty theme picker helper regression + run: | + set -euo pipefail + CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \ + ./tests/test_bundled_ghostty_theme_picker_helper.sh + - name: Run CLI version memory guard regression run: | set -euo pipefail @@ -224,6 +236,12 @@ jobs: run: | ./scripts/download-prebuilt-ghosttykit.sh + - name: Install zig + run: | + if ! command -v zig >/dev/null 2>&1; then + brew install zig + fi + - name: Clean DerivedData run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ae31c277..287aef6a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -153,6 +153,9 @@ jobs: - name: Install build deps if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' run: | + if ! command -v zig >/dev/null 2>&1; then + brew install zig + fi npm install --global "create-dmg@${CREATE_DMG_VERSION}" - name: Download pre-built GhosttyKit.xcframework @@ -202,12 +205,16 @@ jobs: set -euo pipefail APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux" CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" + HELPER_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty" APP_ARCHS="$(lipo -archs "$APP_BINARY")" CLI_ARCHS="$(lipo -archs "$CLI_BINARY")" + HELPER_ARCHS="$(lipo -archs "$HELPER_BINARY")" echo "App binary architectures: $APP_ARCHS" echo "CLI binary architectures: $CLI_ARCHS" + echo "Ghostty helper architectures: $HELPER_ARCHS" [[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]] [[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]] + [[ "$HELPER_ARCHS" == *arm64* && "$HELPER_ARCHS" == *x86_64* ]] - name: Run CLI version memory guard regression if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' @@ -350,9 +357,13 @@ jobs: "build-universal/Build/Products/Release/cmux NIGHTLY.app" do CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" + HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" if [ -f "$CLI_PATH" ]; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH" fi + if [ -f "$HELPER_PATH" ]; then + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH" + fi /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bce4327c..8bb6b124 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,6 +101,9 @@ jobs: - name: Install build deps if: steps.guard_release_assets.outputs.skip_all != 'true' run: | + if ! command -v zig >/dev/null 2>&1; then + brew install zig + fi npm install --global "create-dmg@${CREATE_DMG_VERSION}" - name: Download pre-built GhosttyKit.xcframework @@ -165,6 +168,13 @@ jobs: [ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; } CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py + - name: Verify bundled Ghostty theme picker helper + if: steps.guard_release_assets.outputs.skip_all != 'true' + run: | + set -euo pipefail + HELPER_BINARY="build/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty" + [ -x "$HELPER_BINARY" ] || { echo "Ghostty theme picker helper not found at $HELPER_BINARY" >&2; exit 1; } + - name: Inject Sparkle keys into Info.plist if: steps.guard_release_assets.outputs.skip_all != 'true' run: | @@ -215,9 +225,13 @@ jobs: APP_PATH="build/Build/Products/Release/cmux.app" ENTITLEMENTS="cmux.entitlements" CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" + HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" if [ -f "$CLI_PATH" ]; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH" fi + if [ -f "$HELPER_PATH" ]; then + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH" + fi /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index ca636bf6..536c5a14 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -82,6 +82,12 @@ jobs: rm GhosttyKit.xcframework.tar.gz test -d GhosttyKit.xcframework + - name: Install zig + run: | + if ! command -v zig >/dev/null 2>&1; then + brew install zig + fi + - name: Create virtual display run: | set -euo pipefail diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 23d595c7..a61e5eee 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -90,6 +90,12 @@ jobs: rm GhosttyKit.xcframework.tar.gz test -d GhosttyKit.xcframework + - name: Install zig + run: | + if ! command -v zig >/dev/null 2>&1; then + brew install zig + fi + - name: Create virtual display run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa49338..28610405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to cmux are documented here. +## [0.62.2] - 2026-03-14 + +### Added +- Configurable sidebar tint color with separate light/dark mode support via Settings and config file (`sidebar-background`, `sidebar-tint-opacity`) ([#1465](https://github.com/manaflow-ai/cmux/pull/1465)) +- Cmd+P all-surfaces search option ([#1382](https://github.com/manaflow-ai/cmux/pull/1382)) +- `cmux themes` command with bundled Ghostty themes ([#1334](https://github.com/manaflow-ai/cmux/pull/1334), [#1314](https://github.com/manaflow-ai/cmux/pull/1314)) +- Sidebar can now shrink to smaller widths ([#1420](https://github.com/manaflow-ai/cmux/pull/1420)) +- Menu bar visibility setting ([#1330](https://github.com/manaflow-ai/cmux/pull/1330)) + +### Changed +- CLI Sentry events are now tagged with the app release ([#1408](https://github.com/manaflow-ai/cmux/pull/1408)) +- Stable socket listener now falls back to a user-scoped path, and repeated startup failures are throttled ([#1351](https://github.com/manaflow-ai/cmux/pull/1351), [#1415](https://github.com/manaflow-ai/cmux/pull/1415)) + +### Fixed +- Command palette command-mode shortcut, navigation, and omnibar backspace or arrow-key regressions ([#1417](https://github.com/manaflow-ai/cmux/pull/1417), [#1413](https://github.com/manaflow-ai/cmux/pull/1413)) +- Stale Claude sidebar status from missing hooks, OSC suppression, and PID cleanup ([#1306](https://github.com/manaflow-ai/cmux/pull/1306)) +- Split cwd inheritance when the shell cwd is stale ([#1403](https://github.com/manaflow-ai/cmux/pull/1403)) +- Crashes when creating a new workspace and when inserting a workspace into an orphaned window context ([#1391](https://github.com/manaflow-ai/cmux/pull/1391), [#1380](https://github.com/manaflow-ai/cmux/pull/1380)) +- Cmd+W close behavior and close-confirmation shell-state regressions ([#1395](https://github.com/manaflow-ai/cmux/pull/1395), [#1386](https://github.com/manaflow-ai/cmux/pull/1386)) +- macOS dictation NSTextInputClient conformance and terminal image-paste fallbacks ([#1410](https://github.com/manaflow-ai/cmux/pull/1410), [#1305](https://github.com/manaflow-ai/cmux/pull/1305), [#1361](https://github.com/manaflow-ai/cmux/pull/1361), [#1358](https://github.com/manaflow-ai/cmux/pull/1358)) +- VS Code command palette target resolution, Ghostty Pure prompt redraws, and internal drag regressions ([#1389](https://github.com/manaflow-ai/cmux/pull/1389), [#1363](https://github.com/manaflow-ai/cmux/pull/1363), [#1316](https://github.com/manaflow-ai/cmux/pull/1316), [#1379](https://github.com/manaflow-ai/cmux/pull/1379)) + ## [0.62.1] - 2026-03-13 ### Added diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6df33c0c..83961df7 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -30,6 +30,102 @@ private final class CLISocketSentryTelemetry { private static let startupLock = NSLock() private static var started = false private static let dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416" + + private static func currentSentryReleaseName() -> String? { + guard let bundleIdentifier = currentSentryBundleIdentifier(), + let version = currentBundleVersionValue(forKey: "CFBundleShortVersionString"), + let build = currentBundleVersionValue(forKey: "CFBundleVersion") + else { + return nil + } + return "\(bundleIdentifier)@\(version)+\(build)" + } + + private static func currentSentryBundleIdentifier() -> String? { + if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty { + return bundleIdentifier + } + + if let bundleIdentifier = currentSentryBundle()?.bundleIdentifier? + .trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty { + return bundleIdentifier + } + + return nil + } + + private static func currentBundleVersionValue(forKey key: String) -> String? { + guard let value = currentSentryBundle()?.infoDictionary?[key] as? String else { + return nil + } + + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains("$(") else { + return nil + } + return trimmed + } + + private static func currentSentryBundle() -> Bundle? { + if Bundle.main.bundleIdentifier?.isEmpty == false { + return Bundle.main + } + + guard let executableURL = currentExecutableURL() else { + return Bundle.main + } + + var current = executableURL.deletingLastPathComponent().standardizedFileURL + while true { + if current.pathExtension == "app", let bundle = Bundle(url: current) { + return bundle + } + + if current.lastPathComponent == "Contents" { + let appURL = current.deletingLastPathComponent().standardizedFileURL + if appURL.pathExtension == "app", let bundle = Bundle(url: appURL) { + return bundle + } + } + + guard let parent = parentSearchURL(for: current) else { + break + } + current = parent + } + + return Bundle.main + } + + private static func currentExecutableURL() -> URL? { + var size: UInt32 = 0 + _ = _NSGetExecutablePath(nil, &size) + if size > 0 { + var buffer = Array(repeating: 0, count: Int(size)) + if _NSGetExecutablePath(&buffer, &size) == 0 { + return URL(fileURLWithPath: String(cString: buffer)).standardizedFileURL + } + } + + return Bundle.main.executableURL?.standardizedFileURL + } + + private static func parentSearchURL(for url: URL) -> URL? { + let standardized = url.standardizedFileURL + let path = standardized.path + guard !path.isEmpty, path != "/" else { + return nil + } + + let parent = standardized.deletingLastPathComponent().standardizedFileURL + guard parent.path != path else { + return nil + } + return parent + } #endif init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) { @@ -180,6 +276,7 @@ private final class CLISocketSentryTelemetry { guard !started else { return } SentrySDK.start { options in options.dsn = dsn + options.releaseName = currentSentryReleaseName() #if DEBUG options.environment = "development-cli" #else @@ -226,6 +323,7 @@ private struct ClaudeHookSessionRecord: Codable { var workspaceId: String var surfaceId: String var cwd: String? + var pid: Int? var lastSubtitle: String? var lastBody: String? var startedAt: TimeInterval @@ -273,6 +371,7 @@ private final class ClaudeHookSessionStore { workspaceId: String, surfaceId: String, cwd: String?, + pid: Int? = nil, lastSubtitle: String? = nil, lastBody: String? = nil ) throws { @@ -285,16 +384,22 @@ private final class ClaudeHookSessionStore { workspaceId: workspaceId, surfaceId: surfaceId, cwd: nil, + pid: nil, lastSubtitle: nil, lastBody: nil, startedAt: now, updatedAt: now ) record.workspaceId = workspaceId - record.surfaceId = surfaceId + if !surfaceId.isEmpty { + record.surfaceId = surfaceId + } if let cwd = normalizeOptional(cwd) { record.cwd = cwd } + if let pid { + record.pid = pid + } if let subtitle = normalizeOptional(lastSubtitle) { record.lastSubtitle = subtitle } @@ -9566,39 +9671,68 @@ struct CMUXCLI { workspaceId: workspaceId, client: client ) + let claudePid: Int? = { + guard let raw = ProcessInfo.processInfo.environment["CMUX_CLAUDE_PID"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + let pid = Int(raw), + pid > 0 else { + return nil + } + return pid + }() if let sessionId = parsedInput.sessionId { try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, - cwd: parsedInput.cwd + cwd: parsedInput.cwd, + pid: claudePid + ) + } + // Register PID for stale-session detection and OSC suppression, + // but don't set a visible status. "Running" only appears when the + // user submits a prompt (UserPromptSubmit) or Claude starts working + // (PreToolUse). + if let claudePid { + _ = try? sendV1Command( + "set_agent_pid claude_code \(claudePid) --tab=\(workspaceId)", + client: client ) } - try setClaudeStatus( - client: client, - workspaceId: workspaceId, - value: "Running", - icon: "bolt.fill", - color: "#4C8DFF" - ) print("OK") case "stop", "idle": telemetry.breadcrumb("claude-hook.stop") - let consumedSession = try? sessionStore.consume( - sessionId: parsedInput.sessionId, - workspaceId: fallbackWorkspaceId, - surfaceId: fallbackSurfaceId - ) - let workspaceId = consumedSession?.workspaceId ?? fallbackWorkspaceId - try clearClaudeStatus(client: client, workspaceId: workspaceId) + // Turn ended. Don't consume session or clear PID — Claude is still alive. + // Notification hook handles user-facing notifications; SessionEnd handles cleanup. + var workspaceId = fallbackWorkspaceId + var surfaceId = surfaceArg + if let sessionId = parsedInput.sessionId, + let mapped = try? sessionStore.lookup(sessionId: sessionId), + let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) { + workspaceId = mappedWorkspace + surfaceId = mapped.surfaceId + } - if let completion = summarizeClaudeHookStop( + // Update session with transcript summary and send completion notification. + let completion = summarizeClaudeHookStop( parsedInput: parsedInput, - sessionRecord: consumedSession - ) { - let surfaceId = try resolveSurfaceIdForClaudeHook( - consumedSession?.surfaceId ?? surfaceArg, + sessionRecord: (try? sessionStore.lookup(sessionId: parsedInput.sessionId ?? "")) + ) + if let sessionId = parsedInput.sessionId, let completion { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId ?? "", + cwd: parsedInput.cwd, + lastSubtitle: completion.subtitle, + lastBody: completion.body + ) + } + + if let completion { + let resolvedSurface = try resolveSurfaceIdForClaudeHook( + surfaceId, workspaceId: workspaceId, client: client ) @@ -9606,12 +9740,18 @@ struct CMUXCLI { let subtitle = sanitizeNotificationField(completion.subtitle) let body = sanitizeNotificationField(completion.body) let payload = "\(title)|\(subtitle)|\(body)" - let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") - print(response) - } else { - print("OK") + _ = try? sendV1Command("notify_target \(workspaceId) \(resolvedSurface) \(payload)", client: client) } + try setClaudeStatus( + client: client, + workspaceId: workspaceId, + value: "Idle", + icon: "pause.circle.fill", + color: "#8E8E93" + ) + print("OK") + case "prompt-submit": telemetry.breadcrumb("claude-hook.prompt-submit") var workspaceId = fallbackWorkspaceId @@ -9632,7 +9772,7 @@ struct CMUXCLI { case "notification", "notify": telemetry.breadcrumb("claude-hook.notification") - let summary = summarizeClaudeHookNotification(rawInput: rawInput) + var summary = summarizeClaudeHookNotification(rawInput: rawInput) var workspaceId = fallbackWorkspaceId var preferredSurface = surfaceArg @@ -9641,6 +9781,12 @@ struct CMUXCLI { let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) { workspaceId = mappedWorkspace preferredSurface = mapped.surfaceId + // If PreToolUse saved a richer message (e.g. from AskUserQuestion), + // use it instead of the generic notification text. + if let savedBody = mapped.lastBody, !savedBody.isEmpty, + summary.body.contains("needs your attention") || summary.body.contains("needs your input") { + summary = (subtitle: mapped.lastSubtitle ?? summary.subtitle, body: savedBody) + } } let surfaceId = try resolveSurfaceIdForClaudeHook( @@ -9675,11 +9821,86 @@ struct CMUXCLI { ) print(response) + case "session-end": + telemetry.breadcrumb("claude-hook.session-end") + // Final cleanup when Claude process exits. + // Only clear when we are the primary cleanup path (Stop didn't fire first). + // If Stop already consumed the session, consumedSession is nil and we skip + // to avoid wiping the completion notification that Stop just delivered. + let consumedSession = try? sessionStore.consume( + sessionId: parsedInput.sessionId, + workspaceId: fallbackWorkspaceId, + surfaceId: fallbackSurfaceId + ) + if let consumedSession { + let workspaceId = consumedSession.workspaceId + _ = try? clearClaudeStatus(client: client, workspaceId: workspaceId) + _ = try? sendV1Command("clear_agent_pid claude_code --tab=\(workspaceId)", client: client) + _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) + } + print("OK") + + case "pre-tool-use": + telemetry.breadcrumb("claude-hook.pre-tool-use") + // Clears "Needs input" status and notification when Claude resumes work + // (e.g. after permission grant). Runs async so it doesn't block tool execution. + var workspaceId = fallbackWorkspaceId + var claudePid: Int? = nil + if let sessionId = parsedInput.sessionId, + let mapped = try? sessionStore.lookup(sessionId: sessionId), + let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) { + workspaceId = mappedWorkspace + claudePid = mapped.pid + } + + // AskUserQuestion means Claude is about to ask the user something. + // Save question text in session so the Notification handler can use it + // instead of the generic "Claude Code needs your attention". + if let toolName = parsedInput.object?["tool_name"] as? String, + toolName == "AskUserQuestion", + let question = describeAskUserQuestion(parsedInput.object), + let sessionId = parsedInput.sessionId { + // Preserve the existing surfaceId from SessionStart; passing "" + // would overwrite it and cause notifications to target the wrong workspace. + let existingSurfaceId = (try? sessionStore.lookup(sessionId: sessionId))?.surfaceId ?? "" + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: existingSurfaceId, + cwd: parsedInput.cwd, + lastSubtitle: "Waiting", + lastBody: question + ) + // Don't clear notifications or set status here. + // The Notification hook fires right after and will use the saved question. + print("OK") + return + } + + _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) + + let statusValue: String + if UserDefaults.standard.bool(forKey: "claudeCodeVerboseStatus"), + let toolStatus = describeToolUse(parsedInput.object) { + statusValue = toolStatus + } else { + statusValue = "Running" + } + try setClaudeStatus( + client: client, + workspaceId: workspaceId, + value: statusValue, + icon: "bolt.fill", + color: "#4C8DFF", + pid: claudePid + ) + print("OK") + case "help", "--help", "-h": telemetry.breadcrumb("claude-hook.help") print( """ - cmux claude-hook [--workspace ] [--surface ] + cmux claude-hook [--workspace ] [--surface ] """ ) @@ -9693,17 +9914,105 @@ struct CMUXCLI { workspaceId: String, value: String, icon: String, - color: String + color: String, + pid: Int? = nil ) throws { - _ = try client.send( - command: "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)" - ) + var cmd = "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)" + if let pid { + cmd += " --pid=\(pid)" + } + _ = try client.send(command: cmd) } private func clearClaudeStatus(client: SocketClient, workspaceId: String) throws { _ = try client.send(command: "clear_status claude_code --tab=\(workspaceId)") } + private func describeAskUserQuestion(_ object: [String: Any]?) -> String? { + guard let object, + let input = object["tool_input"] as? [String: Any], + let questions = input["questions"] as? [[String: Any]], + let first = questions.first else { return nil } + + var parts: [String] = [] + + if let question = first["question"] as? String, !question.isEmpty { + parts.append(question) + } else if let header = first["header"] as? String, !header.isEmpty { + parts.append(header) + } + + if let options = first["options"] as? [[String: Any]] { + let labels = options.compactMap { $0["label"] as? String } + if !labels.isEmpty { + parts.append(labels.map { "[\($0)]" }.joined(separator: " ")) + } + } + + if parts.isEmpty { return "Asking a question" } + return parts.joined(separator: "\n") + } + + private func describeToolUse(_ object: [String: Any]?) -> String? { + guard let object, let toolName = object["tool_name"] as? String else { return nil } + let input = object["tool_input"] as? [String: Any] + + switch toolName { + case "Read": + if let path = input?["file_path"] as? String { + return "Reading \(shortenPath(path))" + } + return "Reading file" + case "Edit": + if let path = input?["file_path"] as? String { + return "Editing \(shortenPath(path))" + } + return "Editing file" + case "Write": + if let path = input?["file_path"] as? String { + return "Writing \(shortenPath(path))" + } + return "Writing file" + case "Bash": + if let cmd = input?["command"] as? String { + let first = cmd.components(separatedBy: .whitespacesAndNewlines).first ?? cmd + let short = String(first.prefix(30)) + return "Running \(short)" + } + return "Running command" + case "Glob": + if let pattern = input?["pattern"] as? String { + return "Searching \(String(pattern.prefix(30)))" + } + return "Searching files" + case "Grep": + if let pattern = input?["pattern"] as? String { + return "Grep \(String(pattern.prefix(30)))" + } + return "Searching code" + case "Agent": + if let desc = input?["description"] as? String { + return String(desc.prefix(40)) + } + return "Subagent" + case "WebFetch": + return "Fetching URL" + case "WebSearch": + if let query = input?["query"] as? String { + return "Search: \(String(query.prefix(30)))" + } + return "Web search" + default: + return toolName + } + } + + private func shortenPath(_ path: String) -> String { + let url = URL(fileURLWithPath: path) + let name = url.lastPathComponent + return name.isEmpty ? String(path.suffix(30)) : name + } + private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String { if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) { let probe = try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate]) @@ -9904,20 +10213,13 @@ struct CMUXCLI { let signal = signalParts.compactMap { $0 }.joined(separator: " ") var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage) - if let session, !session.isEmpty { - let shortSession = String(session.prefix(8)) - if !classified.body.contains(shortSession) { - classified.body = "\(classified.body) [\(shortSession)]" - } - } - classified.body = truncate(classified.body, maxLength: 180) return classified } private func classifyClaudeNotification(signal: String, message: String) -> (subtitle: String, body: String) { let lower = "\(signal) \(message)".lowercased() - if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") { + if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") || lower.contains("permission_prompt") { let body = message.isEmpty ? "Approval needed" : message return ("Permission", body) } @@ -9925,12 +10227,19 @@ struct CMUXCLI { let body = message.isEmpty ? "Claude reported an error" : message return ("Error", body) } - if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("prompt") { - let body = message.isEmpty ? "Claude is waiting for your input" : message + if lower.contains("complet") || lower.contains("finish") || lower.contains("done") || lower.contains("success") { + let body = message.isEmpty ? "Task completed" : message + return ("Completed", body) + } + if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("idle_prompt") { + let body = message.isEmpty ? "Waiting for input" : message return ("Waiting", body) } - let body = message.isEmpty ? "Claude needs your input" : message - return ("Attention", body) + // Use the message directly if it's meaningful (not a generic placeholder). + if !message.isEmpty, message != "Claude needs your input" { + return ("Attention", message) + } + return ("Attention", "Claude needs your attention") } private func firstString(in object: [String: Any], keys: [String]) -> String? { @@ -9958,9 +10267,8 @@ struct CMUXCLI { } private func sanitizeNotificationField(_ value: String) -> String { - let normalized = normalizedSingleLine(value) + return normalizedSingleLine(value) .replacingOccurrences(of: "|", with: "¦") - return truncate(normalized, maxLength: 180) } private func versionSummary() -> String { diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 86445cf5..b0de0d8c 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; + A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; }; A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; }; A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; @@ -83,12 +84,14 @@ B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; + FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; @@ -180,6 +183,7 @@ A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; + A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = ""; }; A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = ""; }; A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = ""; }; A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = ""; }; @@ -229,12 +233,14 @@ B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; + FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; @@ -330,7 +336,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CMUXCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :CMUXCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n"; + shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nBIN_DEST=\"${DEST}/bin\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nBUILD_GHOSTTY_HELPER=\"${SRCROOT}/scripts/build-ghostty-cli-helper.sh\"\nGHOSTTY_HELPER_DEST=\"${BIN_DEST}/ghostty\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nif [ ! -x \"$BUILD_GHOSTTY_HELPER\" ]; then\n echo \"error: missing Ghostty CLI helper build script at $BUILD_GHOSTTY_HELPER\" >&2\n exit 1\nfi\nARCHS_LIST=\" ${ARCHS:-} \"\nHAS_ARM64=0\nHAS_X86_64=0\nGHOSTTY_HELPER_TARGET=\"\"\ncase \"$ARCHS_LIST\" in\n *\" arm64 \"*) HAS_ARM64=1 ;;\nesac\ncase \"$ARCHS_LIST\" in\n *\" x86_64 \"*) HAS_X86_64=1 ;;\nesac\nif [ \"$HAS_ARM64\" -eq 1 ] && [ \"$HAS_X86_64\" -eq 1 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --universal --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"aarch64-macos\"\nelif [ \"$HAS_X86_64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"x86_64-macos\"\nfi\nif [ -n \"$GHOSTTY_HELPER_TARGET\" ]; then\n \"$BUILD_GHOSTTY_HELPER\" --target \"$GHOSTTY_HELPER_TARGET\" --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 0 ] || [ \"$HAS_X86_64\" -eq 0 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --output \"$GHOSTTY_HELPER_DEST\"\nfi\nif [ ! -x \"$GHOSTTY_HELPER_DEST\" ]; then\n echo \"error: Ghostty CLI helper was not created at $GHOSTTY_HELPER_DEST\" >&2\n exit 1\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CMUXCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :CMUXCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -387,6 +393,7 @@ A5001412 /* BrowserPanel.swift */, A5001413 /* TerminalPanelView.swift */, A5001414 /* BrowserPanelView.swift */, + A5007421 /* BrowserPopupWindowController.swift */, A5001418 /* MarkdownPanel.swift */, A5001419 /* MarkdownPanelView.swift */, A5001510 /* CmuxWebView.swift */, @@ -456,6 +463,7 @@ B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, + FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */, ); @@ -470,6 +478,7 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, @@ -659,6 +668,7 @@ A5001402 /* BrowserPanel.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, A5001404 /* BrowserPanelView.swift in Sources */, + A5007420 /* BrowserPopupWindowController.swift in Sources */, A5001420 /* MarkdownPanel.swift in Sources */, A5001421 /* MarkdownPanelView.swift in Sources */, A5001500 /* CmuxWebView.swift in Sources */, @@ -696,6 +706,7 @@ B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */, D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */, + FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */, ); @@ -710,6 +721,7 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, @@ -818,7 +830,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 76; + CURRENT_PROJECT_VERSION = 77; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -827,7 +839,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.62.1; + MARKETING_VERSION = 0.62.2; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -857,7 +869,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 76; + CURRENT_PROJECT_VERSION = 77; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -866,7 +878,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.62.1; + MARKETING_VERSION = 0.62.2; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -933,10 +945,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 76; + CURRENT_PROJECT_VERSION = 77; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.62.1; + MARKETING_VERSION = 0.62.2; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -950,10 +962,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 76; + CURRENT_PROJECT_VERSION = 77; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.62.1; + MARKETING_VERSION = 0.62.2; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -967,10 +979,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 76; + CURRENT_PROJECT_VERSION = 77; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.62.1; + MARKETING_VERSION = 0.62.2; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -986,10 +998,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 76; + CURRENT_PROJECT_VERSION = 77; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.62.1; + MARKETING_VERSION = 0.62.2; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Resources/Info.plist b/Resources/Info.plist index 5ec6f1c5..41572e05 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -12,6 +12,21 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + CFBundleDocumentTypes + + + CFBundleTypeName + Folder + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + public.folder + + + CFBundleName $(PRODUCT_NAME) CFBundlePackageType diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 02c147e8..136fcea1 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -551,7 +551,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "A Ghostty-based terminal with vertical tabs\\nand a notification panel for macOS." + "value": "A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS." } }, "ja": { @@ -563,43 +563,43 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "基于 Ghostty 的 macOS 终端,\\n支持垂直标签页和通知面板。" + "value": "基于 Ghostty 的 macOS 终端,\n支持垂直标签页和通知面板。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "基於 Ghostty 的 macOS 終端機,\\n具備垂直標籤頁與通知面板。" + "value": "基於 Ghostty 的 macOS 終端機,\n具備垂直標籤頁與通知面板。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "세로 탭과 알림 패널을 갖춘\\nGhostty 기반 macOS 터미널." + "value": "세로 탭과 알림 패널을 갖춘\nGhostty 기반 macOS 터미널." } }, "de": { "stringUnit": { "state": "translated", - "value": "Ein Ghostty-basiertes Terminal mit vertikalen Tabs\\nund einem Benachrichtigungsfeld für macOS." + "value": "Ein Ghostty-basiertes Terminal mit vertikalen Tabs\nund einem Benachrichtigungsfeld für macOS." } }, "es": { "stringUnit": { "state": "translated", - "value": "Un terminal basado en Ghostty con pestañas verticales\\ny un panel de notificaciones para macOS." + "value": "Un terminal basado en Ghostty con pestañas verticales\ny un panel de notificaciones para macOS." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Un terminal basé sur Ghostty avec des onglets verticaux\\net un panneau de notifications pour macOS." + "value": "Un terminal basé sur Ghostty avec des onglets verticaux\net un panneau de notifications pour macOS." } }, "it": { "stringUnit": { "state": "translated", - "value": "Un terminale basato su Ghostty con schede verticali\\ne un pannello notifiche per macOS." + "value": "Un terminale basato su Ghostty con schede verticali\ne un pannello notifiche per macOS." } }, "da": { @@ -617,43 +617,43 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Терминал на базе Ghostty с вертикальными вкладками\\nи панелью уведомлений для macOS." + "value": "Терминал на базе Ghostty с вертикальными вкладками\nи панелью уведомлений для macOS." } }, "bs": { "stringUnit": { "state": "translated", - "value": "Terminal zasnovan na Ghostty sa vertikalnim tabovima\\ni panelom za obavještenja za macOS." + "value": "Terminal zasnovan na Ghostty sa vertikalnim tabovima\ni panelom za obavještenja za macOS." } }, "ar": { "stringUnit": { "state": "translated", - "value": "طرفية مبنية على Ghostty مع ألسنة عمودية\\nولوحة إشعارات لنظام macOS." + "value": "طرفية مبنية على Ghostty مع ألسنة عمودية\nولوحة إشعارات لنظام macOS." } }, "nb": { "stringUnit": { "state": "translated", - "value": "En Ghostty-basert terminal med vertikale faner\\nog et varselpanel for macOS." + "value": "En Ghostty-basert terminal med vertikale faner\nog et varselpanel for macOS." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Um terminal baseado no Ghostty com abas verticais\\ne um painel de notificações para macOS." + "value": "Um terminal baseado no Ghostty com abas verticais\ne um painel de notificações para macOS." } }, "th": { "stringUnit": { "state": "translated", - "value": "เทอร์มินัลบน Ghostty พร้อมแท็บแนวตั้ง\\nและแผงการแจ้งเตือนสำหรับ macOS" + "value": "เทอร์มินัลบน Ghostty พร้อมแท็บแนวตั้ง\nและแผงการแจ้งเตือนสำหรับ macOS" } }, "tr": { "stringUnit": { "state": "translated", - "value": "macOS için dikey sekmeli ve bildirim panelli\\nGhostty tabanlı terminal." + "value": "macOS için dikey sekmeli ve bildirim panelli\nGhostty tabanlı terminal." } } } @@ -4564,6 +4564,1332 @@ } } }, + "browser.profile.buttonHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Profile: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイル: %@" + } + } + } + }, + "browser.profile.default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルト" + } + } + } + }, + "browser.profile.menu.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profiles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル" + } + } + } + }, + "browser.profile.new": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Profile..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいプロファイル..." + } + } + } + }, + "browser.profile.new.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create a separate browser profile for cookies, history, and local storage." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookie、履歴、ローカルストレージを分けるためのブラウザープロファイルを作成します。" + } + } + } + }, + "browser.profile.new.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル名" + } + } + } + }, + "browser.profile.new.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Browser Profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいブラウザープロファイル" + } + } + } + }, + "browser.profile.rename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Current Profile..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のプロファイル名を変更..." + } + } + } + }, + "browser.profile.rename.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a new name for this browser profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このブラウザープロファイルの新しい名前を入力します。" + } + } + } + }, + "browser.profile.rename.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Browser Profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイル名を変更" + } + } + } + }, + "browser.import.additionalData.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bookmarks, settings, and extensions import are not available yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブックマーク、設定、拡張機能のインポートにはまだ対応していません。" + } + } + } + }, + "browser.import.additionalData": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Additional data (bookmarks, settings, extensions)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加データ(ブックマーク、設定、拡張機能)" + } + } + } + }, + "browser.import.back": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻る" + } + } + } + }, + "browser.import.complete.browser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ: %@" + } + } + } + }, + "browser.import.complete.createdProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Created cmux profiles: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成した cmux プロファイル: %@" + } + } + } + }, + "browser.import.complete.destinationProfile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Destination profile: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存先プロファイル: %@" + } + } + } + }, + "browser.import.complete.domainFilter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Domain filter: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドメインフィルタ: %@" + } + } + } + }, + "browser.import.complete.profileMapping": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ -> %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ -> %2$@" + } + } + } + }, + "browser.import.complete.profileMappings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile mappings:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル対応:" + } + } + } + }, + "browser.import.complete.importedCookies": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Imported cookies: %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートしたCookie: %ld" + } + } + } + }, + "browser.import.complete.importedHistory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Imported history entries: %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートした履歴件数: %ld" + } + } + } + }, + "browser.import.complete.scope": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scope: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対象: %@" + } + } + } + }, + "browser.import.complete.skippedCookies": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped cookies: %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップしたCookie: %ld" + } + } + } + }, + "browser.import.complete.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source profiles: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元プロファイル: %@" + } + } + } + }, + "browser.import.complete.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser data import complete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータのインポートが完了しました" + } + } + } + }, + "browser.import.complete.warnings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warnings:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "警告:" + } + } + } + }, + "browser.import.cookies": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cookies (site sign-ins)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookie(サイトのログイン状態)" + } + } + } + }, + "browser.import.destination.cmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux destination" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux の保存先" + } + } + } + }, + "browser.import.destinationProfile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import into" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート先" + } + } + } + }, + "browser.import.destinationProfile.create": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create \"%@\"" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" を作成" + } + } + } + }, + "browser.import.destinationProfile.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Imported cookies and history go into the selected cmux browser profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートしたCookieと履歴は、選択したcmuxブラウザープロファイルに保存されます。" + } + } + } + }, + "browser.import.destinationProfile.mergeHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All selected source profiles will be merged into the chosen cmux browser profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した元プロファイルはすべて、選んだ cmux ブラウザープロファイルにまとめて取り込まれます。" + } + } + } + }, + "browser.import.destinationProfile.separateHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing cmux profiles are created when import starts." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不足している cmux プロファイルは、インポート開始時に作成されます。" + } + } + } + }, + "browser.import.destinationMode.merge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Merge all into one cmux profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてを1つの cmux プロファイルにまとめる" + } + } + } + }, + "browser.import.destinationMode.separate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep profiles separate" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルを分けたまま取り込む" + } + } + } + }, + "browser.import.detected.all": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detected: %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出済み: %@。" + } + } + } + }, + "browser.import.detected.more.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detected: %@, +1 more." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出済み: %@、ほか1件。" + } + } + } + }, + "browser.import.detected.more.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Detected: %@, +%ld more." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出済み: %@、ほか%ld件。" + } + } + } + }, + "browser.import.detected.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No supported browsers detected." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対応しているブラウザーが見つかりませんでした。" + } + } + } + }, + "browser.import.domain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Limit to" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対象ドメイン" + } + } + } + }, + "browser.import.domain.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Optional domains only (e.g. github.com, openai.com)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "任意のドメインのみ(例: github.com, openai.com)" + } + } + } + }, + "browser.import.error.destinationCreateFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not create the destination profile \"%@\"." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux は保存先プロファイル「%@」を作成できませんでした。" + } + } + } + }, + "browser.import.error.destinationMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The selected cmux browser profile no longer exists. Pick a destination profile again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した cmux ブラウザープロファイルが見つかりません。保存先プロファイルを選び直してください。" + } + } + } + }, + "browser.import.error.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import could not start" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートを開始できませんでした" + } + } + } + }, + "browser.import.history": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History (visited pages)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴(訪問したページ)" + } + } + } + }, + "browser.import.next": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次へ" + } + } + } + }, + "browser.import.noBrowsers.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not find browser profiles to import from on this Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このMacでインポート元にできるブラウザープロファイルが見つかりませんでした。" + } + } + } + }, + "browser.import.noBrowsers.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No importable browsers found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートできるブラウザーが見つかりません" + } + } + } + }, + "browser.import.progress.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Importing %@ from %@…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%2$@ から %1$@ をインポート中…" + } + } + } + }, + "browser.import.progress.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This can take a few seconds for large profiles." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイルが大きい場合は数秒かかることがあります。" + } + } + } + }, + "browser.import.progress.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Importing Browser Data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート中" + } + } + } + }, + "browser.import.scope.cookiesAndHistory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cookies + history" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookie + 履歴" + } + } + } + }, + "browser.import.scope.cookiesOnly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cookies only" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cookieのみ" + } + } + } + }, + "browser.import.scope.everything": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Everything" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて" + } + } + } + }, + "browser.import.scope.historyOnly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History only" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴のみ" + } + } + } + }, + "browser.import.source": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート元" + } + } + } + }, + "browser.import.sourceProfile.fallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile %ld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロファイル%ld" + } + } + } + }, + "browser.import.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source Profiles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元プロファイル" + } + } + } + }, + "browser.import.sourceProfiles.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose one or more source profiles. Step 3 lets you keep them separate or merge them into one cmux profile." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元プロファイルを1つ以上選択してください。3 / 3 で、分けたまま取り込むか、1つの cmux プロファイルにまとめるかを選べます。" + } + } + } + }, + "browser.import.sourceProfiles.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No source profiles detected for %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の元プロファイルが見つかりません。" + } + } + } + }, + "browser.import.start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start Import" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート開始" + } + } + } + }, + "browser.import.step.dataTypes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Step 3 of 3: Choose what to import from %@ and where to put it." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "3 / 3: %@ から何をインポートし、どこに保存するかを選択します。" + } + } + } + }, + "browser.import.step.source": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Step 1 of 3: Choose the browser to import from." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 / 3: インポート元のブラウザーを選択します。" + } + } + } + }, + "browser.import.step.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Step 2 of 3: Choose source profiles from %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "2 / 3: %@ の元プロファイルを選択します。" + } + } + } + }, + "browser.import.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import Browser Data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート" + } + } + } + }, + "browser.import.validation.scope": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select Cookies, History, or both before starting import." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートを始める前に、Cookie、履歴、またはその両方を選択してください。" + } + } + } + }, + "browser.import.validation.sourceProfiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose at least one source profile to import." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートする元プロファイルを少なくとも1つ選択してください。" + } + } + } + }, + "browser.import.warning.additionalDataUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bookmarks, settings, and extensions import are not available yet. Imported cookies and history only." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブックマーク、設定、拡張機能のインポートにはまだ対応していません。Cookieと履歴のみを取り込みました。" + } + } + } + }, + "browser.import.warning.browserCookiesReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading %@ cookies at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ のCookieを %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.browserHistoryReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading %@ history at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の履歴を %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.cookieImportUnsupported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ cookie import is not implemented yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ のCookieインポートにはまだ対応していません。" + } + } + } + }, + "browser.import.warning.encryptedCookiesSkipped": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped %ld encrypted cookies that require Keychain decryption." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychainでの復号が必要な暗号化Cookieを%ld件スキップしました。" + } + } + } + }, + "browser.import.warning.keychainDecryptFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped %ld encrypted %@ cookies because %@ could not be unlocked from Keychain." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keychain から %3$@ を開けなかったため、暗号化された %2$@ のCookieを%1$ld件スキップしました。" + } + } + } + }, + "browser.import.warning.firefoxCookiesReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading Firefox cookies at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Firefox のCookieを %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.firefoxHistoryReadFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed reading Firefox history at %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Firefox の履歴を %@ から読み込めませんでした: %@" + } + } + } + }, + "browser.import.warning.noHistoryDatabase": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No history database found for %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の履歴データベースが見つかりませんでした。" + } + } + } + }, + "browser.import.warning.safariCookiesUnsupported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Safari cookies are stored in Cookies.binarycookies and are not yet supported by this importer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Safari のCookieは Cookies.binarycookies に保存されており、このインポーターではまだ対応していません。" + } + } + } + }, + "browser.theme.buttonHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Theme: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーテーマ: %@" + } + } + } + }, "browser.addressBarSuggestions": { "extractionState": "manual", "localizations": { @@ -7276,6 +8602,23 @@ } } }, + "browser.popup.loadingTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading\u2026" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "\u8aad\u307f\u8fbc\u307f\u4e2d\u2026" + } + } + } + }, "browser.proceedInCmux": { "extractionState": "manual", "localizations": { @@ -19932,6 +21275,57 @@ } } }, + "commandPalette.kind.browser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ" + } + } + } + }, + "commandPalette.kind.markdown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Markdown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Markdown" + } + } + } + }, + "commandPalette.kind.terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, "commandPalette.kind.workspace": { "extractionState": "manual", "localizations": { @@ -21627,6 +23021,23 @@ } } }, + "commandPalette.search.switcherEmptyAllSurfaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No workspaces or surfaces match your search." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索に一致するワークスペースまたはサーフェスはありません。" + } + } + } + }, "commandPalette.search.switcherPlaceholder": { "extractionState": "manual", "localizations": { @@ -21740,6 +23151,23 @@ } } }, + "commandPalette.search.switcherPlaceholderAllSurfaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search workspaces and surfaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースとサーフェスを検索" + } + } + } + }, "commandPalette.subtitle.browserWithName": { "extractionState": "manual", "localizations": { @@ -22870,6 +24298,23 @@ } } }, + "common.create": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成" + } + } + } + }, "common.close": { "extractionState": "manual", "localizations": { @@ -36521,6 +37966,23 @@ } } }, + "menu.view.importFromBrowser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import From Browser…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザから取り込む…" + } + } + } + }, "menu.view.forward": { "extractionState": "manual", "localizations": { @@ -41431,6 +42893,57 @@ } } }, + "settings.app.commandPaletteSearchAllSurfaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command Palette Searches All Surfaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドパレットですべてのサーフェスを検索" + } + } + } + }, + "settings.app.commandPaletteSearchAllSurfaces.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cmd+P matches workspace rows only." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Pはワークスペース行だけを対象にします。" + } + } + } + }, + "settings.app.commandPaletteSearchAllSurfaces.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cmd+Pでワークスペースをまたいだターミナル、ブラウザ、Markdownのサーフェスも検索できます。" + } + } + } + }, "settings.app.closeWorkspaceOnLastSurfaceShortcut": { "extractionState": "manual", "localizations": { @@ -49252,6 +50765,40 @@ } } }, + "settings.browser.emptyImport.choose": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose What to Import…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "取り込む項目を選ぶ…" + } + } + } + }, + "settings.browser.emptyImport.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import browser data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザデータを取り込む" + } + } + } + }, "settings.browser.history": { "extractionState": "manual", "localizations": { @@ -49365,6 +50912,57 @@ } } }, + "settings.browser.import": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import From Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザから取り込む" + } + } + } + }, + "settings.browser.import.choose": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択…" + } + } + } + }, + "settings.browser.import.refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込み" + } + } + } + }, "settings.browser.history.clearButton": { "extractionState": "manual", "localizations": { @@ -73835,6 +75433,1475 @@ } } } + }, + "settings.section.sidebarAppearance": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar Appearance" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーの外観" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "侧边栏外观" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "側邊欄外觀" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 모양" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleisten-Erscheinungsbild" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Apariencia de la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Apparence de la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aspetto barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sidebjælkeudseende" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wygląd paska bocznego" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внешний вид боковой панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Izgled bočne trake" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مظهر الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sidefelts utseende" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aparência da Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รูปลักษณ์แถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğu Görünümü" + } + } + } + }, + "settings.sidebarAppearance.tintColorLight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light Mode Tint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライトモードのティント" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浅色模式色调" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "淺色模式色調" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이트 모드 색조" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbton im hellen Modus" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tinte del modo claro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Teinte du mode clair" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tinta modalità chiara" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Lys tilstand farvetone" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odcień trybu jasnego" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Оттенок светлого режима" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nijansa svijetlog načina" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون الوضع الفاتح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fargetone for lys modus" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tonalidade do Modo Claro" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โทนสีโหมดสว่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açık Mod Renk Tonu" + } + } + } + }, + "settings.sidebarAppearance.tintColorLight.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar tint color when using light appearance." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライト表示時のサイドバーのティントカラー。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用浅色外观时侧边栏的色调颜色。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用淺色外觀時側邊欄的色調顏色。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이트 모양 사용 시 사이드바 색조 색상." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleisten-Farbton bei hellem Erscheinungsbild." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Color de tinte de la barra lateral en apariencia clara." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur de teinte de la barre latérale en apparence claire." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colore tinta della barra laterale in modalità chiara." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sidebjælkens farvetone ved lyst udseende." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolor odcienia paska bocznego w jasnym wyglądzie." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет оттенка боковой панели в светлом режиме." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Boja nijanse bočne trake pri svijetlom izgledu." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون تلوين الشريط الجانبي عند استخدام المظهر الفاتح." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fargetone for sidefeltet med lyst utseende." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cor de tonalidade da barra lateral na aparência clara." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีโทนของแถบด้านข้างเมื่อใช้รูปลักษณ์สว่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açık görünüm kullanılırken kenar çubuğu renk tonu." + } + } + } + }, + "settings.sidebarAppearance.tintColorLight.picker": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light tint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライトティント" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浅色色调" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "淺色色調" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이트 색조" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heller Farbton" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tinte claro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Teinte claire" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tinta chiara" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Lys farvetone" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jasny odcień" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Светлый оттенок" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Svijetla nijansa" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون فاتح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lys fargetone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tonalidade clara" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โทนสว่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açık ton" + } + } + } + }, + "settings.sidebarAppearance.tintColorDark": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark Mode Tint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダークモードのティント" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "深色模式色调" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "深色模式色調" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다크 모드 색조" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbton im dunklen Modus" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tinte del modo oscuro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Teinte du mode sombre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tinta modalità scura" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Mørk tilstand farvetone" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odcień trybu ciemnego" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Оттенок тёмного режима" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nijansa tamnog načina" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون الوضع الداكن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fargetone for mørk modus" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tonalidade do Modo Escuro" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โทนสีโหมดมืด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Koyu Mod Renk Tonu" + } + } + } + }, + "settings.sidebarAppearance.tintColorDark.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar tint color when using dark appearance." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダーク表示時のサイドバーのティントカラー。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用深色外观时侧边栏的色调颜色。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用深色外觀時側邊欄的色調顏色。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다크 모양 사용 시 사이드바 색조 색상." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleisten-Farbton bei dunklem Erscheinungsbild." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Color de tinte de la barra lateral en apariencia oscura." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur de teinte de la barre latérale en apparence sombre." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colore tinta della barra laterale in modalità scura." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sidebjælkens farvetone ved mørkt udseende." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolor odcienia paska bocznego w ciemnym wyglądzie." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет оттенка боковой панели в тёмном режиме." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Boja nijanse bočne trake pri tamnom izgledu." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون تلوين الشريط الجانبي عند استخدام المظهر الداكن." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fargetone for sidefeltet med mørkt utseende." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cor de tonalidade da barra lateral na aparência escura." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีโทนของแถบด้านข้างเมื่อใช้รูปลักษณ์มืด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Koyu görünüm kullanılırken kenar çubuğu renk tonu." + } + } + } + }, + "settings.sidebarAppearance.tintColorDark.picker": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark tint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダークティント" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "深色色调" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "深色色調" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다크 색조" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dunkler Farbton" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tinte oscuro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Teinte sombre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tinta scura" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Mørk farvetone" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ciemny odcień" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тёмный оттенок" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Tamna nijansa" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون داكن" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Mørk fargetone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tonalidade escura" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โทนมืด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Koyu ton" + } + } + } + }, + "settings.sidebarAppearance.tintOpacity": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tint Opacity" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ティントの不透明度" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "色调不透明度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "色調不透明度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "색조 불투명도" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbton-Deckkraft" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Opacidad del tinte" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Opacité de la teinte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Opacità tinta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Farvetone gennemsigtighed" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Krycie odcienia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Непрозрачность оттенка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prozirnost nijanse" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "شفافية اللون" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fargetone-opasitet" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Opacidade da Tonalidade" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ความทึบของโทนสี" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Renk Tonu Opaklığı" + } + } + } + }, + "settings.sidebarAppearance.tintOpacity.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "How strongly the tint color shows over the sidebar material." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーの素材上にティントカラーがどの程度表示されるか。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "色调颜色在侧边栏材质上的显示强度。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "色調顏色在側邊欄材質上的顯示強度。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 재질 위에 색조 색상이 표시되는 강도." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie stark der Farbton über dem Seitenleisten-Material sichtbar ist." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Intensidad del color de tinte sobre el material de la barra lateral." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Intensité de la teinte sur le matériau de la barre latérale." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Intensità della tinta sul materiale della barra laterale." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Hvor stærkt farvetonen vises over sidebjælkens materiale." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jak silnie kolor odcienia jest widoczny na materiale paska bocznego." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Насколько сильно оттенок виден на материале боковой панели." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Koliko jako se boja nijanse prikazuje preko materijala bočne trake." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مدى قوة ظهور لون التلوين فوق مادة الشريط الجانبي." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Hvor sterkt fargetonen vises over sidefelts materiale." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Intensidade da cor de tonalidade sobre o material da barra lateral." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ความเข้มของสีโทนที่แสดงบนวัสดุแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Renk tonunun kenar çubuğu materyali üzerinde ne kadar güçlü göründüğü." + } + } + } + }, + "settings.sidebarAppearance.reset": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset Sidebar Tint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーのティントをリセット" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置侧边栏色调" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置側邊欄色調" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 색조 초기화" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleisten-Farbton zurücksetzen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer tinte de la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser la teinte de la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ripristina tinta barra laterale" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nulstil sidebjælkens farvetone" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Resetuj odcień paska bocznego" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбросить оттенок боковой панели" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Resetuj nijansu bočne trake" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تعيين لون الشريط الجانبي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilbakestill sidefelts fargetone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Redefinir Tonalidade da Barra Lateral" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีเซ็ตโทนสีแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğu Renk Tonunu Sıfırla" + } + } + } + }, + "settings.sidebarAppearance.reset.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore default sidebar appearance." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーの外観をデフォルトに戻す。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复默认侧边栏外观。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "恢復預設側邊欄外觀。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 모양을 기본값으로 복원합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standard-Seitenleisten-Erscheinungsbild wiederherstellen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restaurar la apariencia predeterminada de la barra lateral." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Restaurer l'apparence par défaut de la barre latérale." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ripristina l'aspetto predefinito della barra laterale." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gendan standardudseendet for sidebjælken." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przywróć domyślny wygląd paska bocznego." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановить внешний вид боковой панели по умолчанию." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vrati zadani izgled bočne trake." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "استعادة مظهر الشريط الجانبي الافتراضي." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gjenopprett standard sidefelts utseende." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Restaurar aparência padrão da barra lateral." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คืนค่ารูปลักษณ์แถบด้านข้างเป็นค่าเริ่มต้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Varsayılan kenar çubuğu görünümünü geri yükle." + } + } + } + }, + "settings.sidebarAppearance.reset.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "초기화" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurücksetzen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ripristina" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nulstil" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Resetuj" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбросить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Resetuj" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تعيين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilbakestill" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Redefinir" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รีเซ็ต" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sıfırla" + } + } + } + }, + "settings.sidebarAppearance.defaultLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본값" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standard" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Predeterminado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Par défaut" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Predefinito" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Standard" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Domyślny" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "По умолчанию" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zadano" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "افتراضي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Standard" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Padrão" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ค่าเริ่มต้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Varsayılan" + } + } + } } } } diff --git a/Resources/bin/claude b/Resources/bin/claude index 02939248..763bf5b1 100755 --- a/Resources/bin/claude +++ b/Resources/bin/claude @@ -76,9 +76,17 @@ for arg in "$@"; do esac done +# Export the wrapper's PID. Because we exec claude below, this PID becomes +# the actual claude process PID, which hooks use for stale-session detection. +export CMUX_CLAUDE_PID=$$ + # Build hooks settings JSON. # Claude Code merges --settings additively with the user's own settings.json. -HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}]}}' +# - SessionStart/Stop/Notification: existing lifecycle hooks +# - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't fire) +# - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt +# - PreToolUse: clears "Needs input" when Claude resumes after permission grant (async to avoid latency) +HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook pre-tool-use","timeout":5,"async":true}]}]}}' if [[ "$SKIP_SESSION_ID" == true ]]; then exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@" diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 4a22e3a1..fc1d4cf1 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -145,6 +145,40 @@ _cmux_pr_output_indicates_no_pull_request() { || "$output" == *"no pull request associated"* ]] } +_cmux_github_repo_slug_for_path() { + local repo_path="$1" + local remote_url="" path_part="" + [[ -n "$repo_path" ]] || return 0 + + remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)" + [[ -n "$remote_url" ]] || return 0 + + case "$remote_url" in + git@github.com:*) + path_part="${remote_url#git@github.com:}" + ;; + ssh://git@github.com/*) + path_part="${remote_url#ssh://git@github.com/}" + ;; + https://github.com/*) + path_part="${remote_url#https://github.com/}" + ;; + http://github.com/*) + path_part="${remote_url#http://github.com/}" + ;; + git://github.com/*) + path_part="${remote_url#git://github.com/}" + ;; + *) + return 0 + ;; + esac + + path_part="${path_part%.git}" + [[ "$path_part" == */* ]] || return 0 + printf '%s\n' "$path_part" +} + _cmux_report_pr_for_path() { local repo_path="$1" [[ -n "$repo_path" ]] || { @@ -159,18 +193,26 @@ _cmux_report_pr_for_path() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 - local branch gh_output gh_error="" err_file="" gh_status number state url status_opt="" + local branch repo_slug="" gh_output="" gh_error="" err_file="" gh_status number state url status_opt="" + local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0 + local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0 + local -a gh_repo_args=() branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then _cmux_clear_pr_for_panel return 0 fi + repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")" + if [[ -n "$repo_slug" ]]; then + gh_repo_args=(--repo "$repo_slug") + fi err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" [[ -n "$err_file" ]] || return 1 gh_output="$( builtin cd "$repo_path" 2>/dev/null \ && gh pr view \ + "${gh_repo_args[@]}" \ --json number,state,url \ --jq '[.number, .state, .url] | @tsv' \ 2>"$err_file" @@ -180,18 +222,54 @@ _cmux_report_pr_for_path() { gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true fi - if (( gh_status != 0 )); then - if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then - _cmux_clear_pr_for_panel - return 0 + + if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then + : + else + if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then + implicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + implicit_probe_indicates_no_pr=1 + fi + + # `gh pr view` without an explicit branch can fail to resolve the + # current worktree branch even when the branch has a PR. Fall back to + # the explicit branch name before concluding there is no PR. + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + explicit_branch_output="$( + builtin cd "$repo_path" 2>/dev/null \ + && gh pr view "$branch" \ + "${gh_repo_args[@]}" \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" + )" + explicit_branch_status=$? + if [[ -f "$err_file" ]]; then + explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi + + if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then + gh_output="$explicit_branch_output" + gh_status=0 + else + if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then + explicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then + explicit_probe_indicates_no_pr=1 + fi + + if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then + _cmux_clear_pr_for_panel + return 0 + fi + + # Preserve the last-known PR badge when gh fails transiently, then retry + # on the next background poll instead of clearing visible state. + return 1 fi - # Preserve the last-known PR badge when gh fails transiently, then retry - # on the next background poll instead of clearing visible state. - return 1 - fi - if [[ -z "$gh_output" ]]; then - _cmux_clear_pr_for_panel - return 0 fi IFS=$'\t' read -r number state url <<< "$gh_output" @@ -263,11 +341,9 @@ _cmux_run_pr_probe_with_timeout() { _cmux_stop_pr_poll_loop() { if [[ -n "$_CMUX_PR_POLL_PID" ]]; then - _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM - sleep 0.1 - if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then - _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL - fi + # Use SIGKILL directly to avoid blocking sleep in preexec. + # The poll loop is lightweight and safe to kill abruptly. + _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL _CMUX_PR_POLL_PID="" fi } @@ -378,11 +454,18 @@ _cmux_prompt_command() { if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then local head_signature head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" - if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then - _CMUX_GIT_HEAD_SIGNATURE="$head_signature" - git_head_changed=1 - # Also invalidate the PR poller so it refreshes with the new branch. - _CMUX_PR_FORCE=1 + if [[ -n "$head_signature" ]]; then + if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + # The first observed HEAD value is just the session baseline. + # Treating it as a branch change clears restore-seeded PR badges + # before the first background probe can confirm the current PR. + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 + # Also invalidate the PR poller so it refreshes with the new branch. + _CMUX_PR_FORCE=1 + fi fi fi diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 7a39fe05..af518b52 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -263,6 +263,40 @@ _cmux_pr_output_indicates_no_pull_request() { || "$output" == *"no pull request associated"* ]] } +_cmux_github_repo_slug_for_path() { + local repo_path="$1" + local remote_url="" path_part="" + [[ -n "$repo_path" ]] || return 0 + + remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)" + [[ -n "$remote_url" ]] || return 0 + + case "$remote_url" in + git@github.com:*) + path_part="${remote_url#git@github.com:}" + ;; + ssh://git@github.com/*) + path_part="${remote_url#ssh://git@github.com/}" + ;; + https://github.com/*) + path_part="${remote_url#https://github.com/}" + ;; + http://github.com/*) + path_part="${remote_url#http://github.com/}" + ;; + git://github.com/*) + path_part="${remote_url#git://github.com/}" + ;; + *) + return 0 + ;; + esac + + path_part="${path_part%.git}" + [[ "$path_part" == */* ]] || return 0 + print -r -- "$path_part" +} + _cmux_report_pr_for_path() { local repo_path="$1" [[ -n "$repo_path" ]] || { @@ -277,18 +311,27 @@ _cmux_report_pr_for_path() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 - local branch gh_output gh_error="" err_file="" number state url status_opt="" gh_status + local branch repo_slug="" gh_output="" gh_error="" err_file="" number state url status_opt="" gh_status + local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0 + local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0 + local -a gh_repo_args + gh_repo_args=() branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then _cmux_clear_pr_for_panel return 0 fi + repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")" + if [[ -n "$repo_slug" ]]; then + gh_repo_args=(--repo "$repo_slug") + fi err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" [[ -n "$err_file" ]] || return 1 gh_output="$( builtin cd "$repo_path" 2>/dev/null \ && gh pr view \ + "${gh_repo_args[@]}" \ --json number,state,url \ --jq '[.number, .state, .url] | @tsv' \ 2>"$err_file" @@ -298,18 +341,54 @@ _cmux_report_pr_for_path() { gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true fi - if (( gh_status != 0 )); then - if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then - _cmux_clear_pr_for_panel - return 0 + + if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then + : + else + if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then + implicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + implicit_probe_indicates_no_pr=1 + fi + + # `gh pr view` without an explicit branch can fail to resolve the + # current worktree branch even when the branch has a PR. Fall back to + # the explicit branch name before concluding there is no PR. + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + explicit_branch_output="$( + builtin cd "$repo_path" 2>/dev/null \ + && gh pr view "$branch" \ + "${gh_repo_args[@]}" \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" + )" + explicit_branch_status=$? + if [[ -f "$err_file" ]]; then + explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi + + if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then + gh_output="$explicit_branch_output" + gh_status=0 + else + if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then + explicit_probe_indicates_no_pr=1 + elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then + explicit_probe_indicates_no_pr=1 + fi + + if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then + _cmux_clear_pr_for_panel + return 0 + fi + + # Keep the last-known PR badge on transient gh failures (auth hiccups, + # API lag after creation, or rate limiting) and retry on the next poll. + return 1 fi - # Keep the last-known PR badge on transient gh failures (auth hiccups, - # API lag after creation, or rate limiting) and retry on the next poll. - return 1 - fi - if [[ -z "$gh_output" ]]; then - _cmux_clear_pr_for_panel - return 0 fi local IFS=$'\t' @@ -382,11 +461,9 @@ _cmux_run_pr_probe_with_timeout() { _cmux_stop_pr_poll_loop() { if [[ -n "$_CMUX_PR_POLL_PID" ]]; then - _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM - sleep 0.1 - if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then - _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL - fi + # Use SIGKILL directly to avoid blocking sleep in preexec. + # The poll loop is lightweight and safe to kill abruptly. + _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL _CMUX_PR_POLL_PID="" fi } @@ -554,14 +631,21 @@ _cmux_precmd() { if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then local head_signature head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" - if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then - _CMUX_GIT_HEAD_SIGNATURE="$head_signature" - git_head_changed=1 - # 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 + if [[ -n "$head_signature" ]]; then + if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + # The first observed HEAD value establishes the baseline for this + # shell session. Don't treat it as a branch change or we'll clear + # restore-seeded PR badges before the first background probe runs. + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 + # 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 fi diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 75bdaf43..e5eaaa3c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -368,13 +368,24 @@ enum FinderServicePathResolver { return canonical } + private static func resolvedDirectoryURL(from url: URL) -> URL { + let standardized = url.standardizedFileURL + if standardized.hasDirectoryPath { + return standardized + } + if let resourceValues = try? standardized.resourceValues(forKeys: [.isDirectoryKey]), + resourceValues.isDirectory == true { + return standardized + } + return standardized.deletingLastPathComponent() + } + static func orderedUniqueDirectories(from pathURLs: [URL]) -> [String] { var seen: Set = [] var directories: [String] = [] for url in pathURLs { - let standardized = url.standardizedFileURL - let directoryURL = standardized.hasDirectoryPath ? standardized : standardized.deletingLastPathComponent() + let directoryURL = resolvedDirectoryURL(from: url) let path = canonicalDirectoryPath(directoryURL.path(percentEncoded: false)) guard !path.isEmpty else { continue } if seen.insert(path).inserted { @@ -1696,6 +1707,32 @@ func shouldRouteTerminalFontZoomShortcutToGhostty( ) != nil } +@discardableResult +func startOrFocusTerminalSearch( + _ terminalSurface: TerminalSurface, + searchFocusNotifier: @escaping (TerminalSurface) -> Void = { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0) + } +) -> Bool { + if terminalSurface.searchState != nil { + searchFocusNotifier(terminalSurface) + return true + } + + if terminalSurface.performBindingAction("start_search") { + DispatchQueue.main.async { [weak terminalSurface] in + guard let terminalSurface, terminalSurface.searchState == nil else { return } + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) + } + return true + } + + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) + return true +} + /// Let AppKit own native Cmd+` window cycling so key-window changes do not /// re-enter our direct-to-menu shortcut path. func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { @@ -2128,6 +2165,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent Self.shared = self } + func application(_ application: NSApplication, open urls: [URL]) { + let directories = externalOpenDirectories(from: urls) + guard !directories.isEmpty else { return } + + prepareForExplicitOpenIntentAtStartup() + for directory in directories { + openWorkspaceForExternalDirectory( + workingDirectory: directory, + debugSource: "application.openURLs" + ) + } + } + func applicationDidFinishLaunching(_ notification: Notification) { let env = ProcessInfo.processInfo.environment let isRunningUnderXCTest = isRunningUnderXCTest(env) @@ -2221,8 +2271,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent configureUserNotifications() installMenuBarVisibilityObserver() syncMenuBarExtraVisibility() - // Sparkle updater is started lazily on first manual check. This avoids any - // first-launch permission prompts and keeps cmux aligned with the update pill UI. + updateController.startUpdaterIfNeeded() } titlebarAccessoryController.start() windowDecorationsController.start() @@ -2337,7 +2386,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent stopSocketListenerHealthMonitor() TerminalController.shared.stop() VSCodeServeWebController.shared.stop() - BrowserHistoryStore.shared.flushPendingSaves() + BrowserProfileStore.shared.flushPendingSaves() if TelemetrySettings.enabledForCurrentLaunch { PostHogAnalytics.shared.flush() } @@ -5052,11 +5101,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent target: ServiceOpenTarget, error: AutoreleasingUnsafeMutablePointer ) { - didHandleExplicitOpenIntentAtStartup = true - if !didAttemptStartupSessionRestore { - startupSessionSnapshot = nil - didAttemptStartupSessionRestore = true - } + prepareForExplicitOpenIntentAtStartup() let pathURLs = servicePathURLs(from: pasteboard) guard !pathURLs.isEmpty else { @@ -5064,7 +5109,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } - let directories = FinderServicePathResolver.orderedUniqueDirectories(from: pathURLs) + let directories = externalOpenDirectories(from: pathURLs) guard !directories.isEmpty else { error.pointee = Self.serviceErrorNoPath return @@ -5109,10 +5154,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func openWorkspaceFromService(workingDirectory: String) { + openWorkspaceForExternalDirectory( + workingDirectory: workingDirectory, + debugSource: "service.openTab" + ) + } + + private func prepareForExplicitOpenIntentAtStartup() { + didHandleExplicitOpenIntentAtStartup = true + if !didAttemptStartupSessionRestore { + startupSessionSnapshot = nil + didAttemptStartupSessionRestore = true + } + } + + private func externalOpenDirectories(from urls: [URL]) -> [String] { + FinderServicePathResolver.orderedUniqueDirectories(from: urls.filter { $0.isFileURL }) + } + + private func openWorkspaceForExternalDirectory( + workingDirectory: String, + debugSource: String + ) { if addWorkspaceInPreferredMainWindow( workingDirectory: workingDirectory, shouldBringToFront: true, - debugSource: "service.openTab" + debugSource: debugSource ) != nil { return } @@ -7902,11 +7969,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let commandPaletteResponderActiveInTargetWindow = commandPaletteTargetWindow.map { isCommandPaletteResponderActive(in: $0) } ?? false - let commandPaletteEffectiveInTargetWindow = + let commandPaletteInteractiveInTargetWindow = commandPaletteVisibleInTargetWindow - || commandPalettePendingOpenInTargetWindow || commandPaletteOverlayVisibleInTargetWindow || commandPaletteResponderActiveInTargetWindow + let commandPaletteEffectiveInTargetWindow = + commandPaletteInteractiveInTargetWindow + || commandPalettePendingOpenInTargetWindow if normalizedFlags.isEmpty, event.keyCode == 53 { let activePaletteWindow = activeCommandPaletteWindow() @@ -7995,7 +8064,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent chars: chars, keyCode: event.keyCode ), - commandPaletteVisibleInTargetWindow, + commandPaletteInteractiveInTargetWindow, let paletteWindow = commandPaletteShortcutWindow { NotificationCenter.default.post( name: .commandPaletteMoveSelection, @@ -8005,7 +8074,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if commandPaletteVisibleInTargetWindow, + if commandPaletteInteractiveInTargetWindow, let paletteWindow = commandPaletteShortcutWindow { let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow) if normalizedFlags.isEmpty, event.keyCode == 53 { @@ -8050,6 +8119,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Scope the omnibar check to the shortcut's routed window context so a // focused omnibar in another window does not suppress Cmd+P here. let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil + let isCommandShiftP = matchShortcut( + event: event, + shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false) + ) + if isCommandShiftP { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP") + return true + } + let isCommandP = !hasFocusedAddressBarInShortcutContext && matchShortcut( event: event, @@ -8061,16 +8140,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - let isCommandShiftP = matchShortcut( - event: event, - shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false) - ) - if isCommandShiftP { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP") - return true - } - if shouldConsumeShortcutWhileCommandPaletteVisible( isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, normalizedFlags: normalizedFlags, @@ -8348,8 +8417,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent event: event, shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) ) { - if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, - targetWindow.identifier?.rawValue == "cmux.settings" { + // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. + // This AppDelegate path is a fallback for cases where AppKit routes the + // event through the global shortcut handler first. + if let targetWindow = [NSApp.keyWindow, event.window] + .compactMap({ $0 }) + .first(where: { $0.identifier?.rawValue == "cmux.browser-popup" }) { +#if DEBUG + dlog("shortcut.cmdW route=browserPopup") +#endif + targetWindow.performClose(nil) + return true + } else if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + cmuxWindowShouldOwnCloseShortcut(targetWindow) { targetWindow.performClose(nil) } else { let responder = event.window?.firstResponder @@ -8781,7 +8861,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { - guard let panelId = tabManager?.openBrowser(url: url, insertAtEnd: insertAtEnd) else { + let preferredProfileID = + tabManager?.focusedBrowserPanel?.profileID + ?? tabManager?.selectedWorkspace?.preferredBrowserProfileID + guard let panelId = tabManager?.openBrowser( + url: url, + preferredProfileID: preferredProfileID, + insertAtEnd: insertAtEnd + ) else { #if DEBUG dlog( "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + @@ -9528,6 +9615,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case ".": return 47 // kVK_ANSI_Period case "`": return 50 // kVK_ANSI_Grave case "\r": return 36 // kVK_Return + case "←": return 123 // kVK_LeftArrow + case "→": return 124 // kVK_RightArrow + case "↓": return 125 // kVK_DownArrow + case "↑": return 126 // kVK_UpArrow default: return nil } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a2667df2..245adf14 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1408,6 +1408,8 @@ struct ContentView: View { @State private var isFeedbackComposerPresented = false @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) + private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser @FocusState private var isCommandPaletteSearchFocused: Bool @@ -1513,6 +1515,7 @@ struct ContentView: View { let title: String let subtitle: String let shortcutHint: String? + let kindLabel: String? let keywords: [String] let dismissOnRun: Bool let action: () -> Void @@ -1655,6 +1658,14 @@ struct ContentView: View { let id: UUID let displayName: String let metadata: CommandPaletteSwitcherSearchMetadata + let surfaces: [CommandPaletteSwitcherFingerprintSurface] + } + + struct CommandPaletteSwitcherFingerprintSurface: Sendable { + let id: UUID + let displayName: String + let kindLabel: String + let metadata: CommandPaletteSwitcherSearchMetadata } struct CommandPaletteSwitcherFingerprintContext: Sendable { @@ -1669,10 +1680,10 @@ struct ContentView: View { hotSpot: NSCursor.resizeLeftRight.hotSpot ) private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" - private static let commandPaletteCommandsPrefix = ">" + nonisolated private static let commandPaletteCommandsPrefix = ">" private static let commandPaletteVisiblePreviewResultLimit = 48 private static let commandPaletteVisiblePreviewCandidateLimit = 192 - private static let minimumSidebarWidth: CGFloat = 186 + private static let minimumSidebarWidth: CGFloat = CGFloat(SessionPersistencePolicy.minimumSidebarWidth) private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 private enum SidebarResizerHandle: Hashable { @@ -1699,10 +1710,19 @@ struct ContentView: View { return max(Self.minimumSidebarWidth, fallbackScreenWidth * Self.maximumSidebarWidthRatio) } + static func clampedSidebarWidth(_ candidate: CGFloat, maximumWidth: CGFloat) -> CGFloat { + let minimumWidth = Self.minimumSidebarWidth + let sanitizedMaximumWidth = max(minimumWidth, maximumWidth.isFinite ? maximumWidth : minimumWidth) + guard candidate.isFinite else { + return CGFloat(SessionPersistencePolicy.defaultSidebarWidth) + } + return max(minimumWidth, min(sanitizedMaximumWidth, candidate)) + } + private func clampSidebarWidthIfNeeded(availableWidth: CGFloat? = nil) { - let nextWidth = max( - Self.minimumSidebarWidth, - min(maxSidebarWidth(availableWidth: availableWidth), sidebarWidth) + let nextWidth = Self.clampedSidebarWidth( + sidebarWidth, + maximumWidth: maxSidebarWidth(availableWidth: availableWidth) ) guard abs(nextWidth - sidebarWidth) > 0.5 else { return } withTransaction(Transaction(animation: nil)) { @@ -1711,12 +1731,7 @@ struct ContentView: View { } 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)) + Self.clampedSidebarWidth(candidate, maximumWidth: maxSidebarWidth()) } private func activateSidebarResizerCursor() { @@ -1907,9 +1922,9 @@ struct ContentView: View { activateSidebarResizerCursor() let startWidth = sidebarDragStartWidth ?? sidebarWidth - let nextWidth = max( - Self.minimumSidebarWidth, - min(maxSidebarWidth(availableWidth: availableWidth), startWidth + value.translation.width) + let nextWidth = Self.clampedSidebarWidth( + startWidth + value.translation.width, + maximumWidth: maxSidebarWidth(availableWidth: availableWidth) ) withTransaction(Transaction(animation: nil)) { sidebarWidth = nextWidth @@ -3099,6 +3114,7 @@ struct ContentView: View { private var commandPaletteCommandListView: some View { let visibleResults = commandPaletteVisibleResults let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) + let commandPaletteListIdentity = "\(commandPaletteListScope.rawValue):\(commandPaletteQuery)" let commandPaletteListMaxHeight: CGFloat = 450 let commandPaletteRowHeight: CGFloat = 24 let commandPaletteEmptyStateHeight: CGFloat = 44 @@ -3108,35 +3124,18 @@ struct ContentView: View { 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) - .accessibilityIdentifier("CommandPaletteSearchField") - .onSubmit { - runSelectedCommandPaletteResult() - } - .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) - } + CommandPaletteSearchFieldRepresentable( + placeholder: commandPaletteSearchPlaceholder, + text: $commandPaletteQuery, + isFocused: Binding( + get: { isCommandPaletteSearchFocused }, + set: { isCommandPaletteSearchFocused = $0 } + ), + onSubmit: runSelectedCommandPaletteResult, + onEscape: { dismissCommandPalette() }, + onMoveSelection: moveCommandPaletteSelection(by:) + ) + .frame(maxWidth: .infinity) } .padding(.horizontal, 9) .padding(.vertical, 7) @@ -3144,7 +3143,9 @@ struct ContentView: View { Divider() ScrollView { - LazyVStack(spacing: 0) { + // Rebuild the full results container on scope/query transitions so + // stale switcher rows cannot linger above command-mode results. + VStack(spacing: 0) { if visibleResults.isEmpty { if commandPaletteHasCurrentResolvedResults { Text(commandPaletteEmptyStateText) @@ -3214,9 +3215,8 @@ struct ContentView: View { } } .scrollTargetLayout() - // Force a fresh row tree per query so rendered labels/actions stay in lockstep. - .id(commandPaletteQuery) } + .id(commandPaletteListIdentity) .frame(height: commandPaletteListHeight) .scrollPosition( id: Binding( @@ -3245,20 +3245,38 @@ struct ContentView: View { updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) resetCommandPaletteSearchFocus() } - .onChange(of: commandPaletteQuery) { _ in + .onChange(of: commandPaletteQuery) { oldValue, newValue in commandPaletteSelectedResultIndex = 0 commandPaletteSelectionAnchorCommandID = nil commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil - scheduleCommandPaletteResultsRefresh() + if Self.commandPaletteShouldResetVisibleResultsForQueryTransition( + oldQuery: oldValue, + newQuery: newValue, + hasVisibleResults: commandPaletteVisibleResultsScope != nil + ) { + cachedCommandPaletteResults = [] + commandPaletteVisibleResults = [] + commandPaletteVisibleResultsScope = nil + commandPaletteVisibleResultsFingerprint = nil + } + scheduleCommandPaletteResultsRefresh(query: newValue) updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) syncCommandPaletteDebugStateForObservedWindow() } .onChange(of: commandPaletteCurrentSearchFingerprint) { _ in - scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true) - updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) - syncCommandPaletteDebugStateForObservedWindow() + Task { @MainActor in + // Let the query-state transition settle first so the forced corpus refresh + // cannot rebuild the old command list after deleting the ">" prefix. + await Task.yield() + scheduleCommandPaletteResultsRefresh( + query: commandPaletteQuery, + forceSearchCorpusRefresh: true + ) + updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) + syncCommandPaletteDebugStateForObservedWindow() + } } .onChange(of: commandPaletteResultsRevision) { _ in let resultIDs = cachedCommandPaletteResults.map(\.id) @@ -3364,6 +3382,243 @@ struct ContentView: View { } } + private final class CommandPaletteNativeTextField: NSTextField { + var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + isBordered = false + isBezeled = false + drawsBackground = false + focusRingType = .none + usesSingleLineMode = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func keyDown(with event: NSEvent) { + if (currentEditor() as? NSTextView)?.hasMarkedText() == true { + super.keyDown(with: event) + return + } + if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true { + return + } + super.keyDown(with: event) + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if (currentEditor() as? NSTextView)?.hasMarkedText() == true { + return super.performKeyEquivalent(with: event) + } + if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true { + return true + } + return super.performKeyEquivalent(with: event) + } + } + + // Keep navigation on the AppKit field editor so deleting the ">" prefix + // cannot drop the palette's arrow-key handlers during the scope switch. + private struct CommandPaletteSearchFieldRepresentable: NSViewRepresentable { + let placeholder: String + @Binding var text: String + @Binding var isFocused: Bool + let onSubmit: () -> Void + let onEscape: () -> Void + let onMoveSelection: (Int) -> Void + + final class Coordinator: NSObject, NSTextFieldDelegate { + var parent: CommandPaletteSearchFieldRepresentable + var isProgrammaticMutation = false + weak var parentField: CommandPaletteNativeTextField? + var pendingFocusRequest: Bool? + var editorTextDidChangeObserver: NSObjectProtocol? + weak var observedEditor: NSTextView? + + init(parent: CommandPaletteSearchFieldRepresentable) { + self.parent = parent + } + + deinit { + detachEditorTextDidChangeObserver() + } + + func controlTextDidChange(_ obj: Notification) { + guard !isProgrammaticMutation else { return } + guard let field = obj.object as? NSTextField else { return } + parent.text = field.stringValue + } + + func controlTextDidBeginEditing(_ obj: Notification) { + if let field = obj.object as? NSTextField, + let editor = field.currentEditor() as? NSTextView { + attachEditorTextDidChangeObserverIfNeeded(editor) + } + if !parent.isFocused { + DispatchQueue.main.async { + self.parent.isFocused = true + } + } + } + + func controlTextDidEndEditing(_ obj: Notification) { + detachEditorTextDidChangeObserver() + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + switch commandSelector { + case #selector(NSResponder.moveDown(_:)): + parent.onMoveSelection(1) + return true + case #selector(NSResponder.moveUp(_:)): + parent.onMoveSelection(-1) + return true + case #selector(NSResponder.insertNewline(_:)): + guard !textView.hasMarkedText() else { return false } + parent.onSubmit() + return true + case #selector(NSResponder.cancelOperation(_:)): + guard !textView.hasMarkedText() else { return false } + parent.onEscape() + return true + default: + return false + } + } + + func handleKeyEvent(_ event: NSEvent, editor: NSTextView?) -> Bool { + guard !(editor?.hasMarkedText() ?? false) else { return false } + + if let delta = commandPaletteSelectionDeltaForKeyboardNavigation( + flags: event.modifierFlags, + chars: event.characters ?? event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode + ) { + parent.onMoveSelection(delta) + return true + } + + if shouldSubmitCommandPaletteWithReturn( + keyCode: event.keyCode, + flags: event.modifierFlags + ) { + parent.onSubmit() + return true + } + + if event.keyCode == 53, + event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + .isEmpty { + parent.onEscape() + return true + } + + return false + } + + func attachEditorTextDidChangeObserverIfNeeded(_ editor: NSTextView) { + if observedEditor !== editor { + detachEditorTextDidChangeObserver() + } + guard editorTextDidChangeObserver == nil else { return } + observedEditor = editor + editorTextDidChangeObserver = NotificationCenter.default.addObserver( + forName: NSText.didChangeNotification, + object: editor, + queue: .main + ) { [weak self] _ in + guard let self, !self.isProgrammaticMutation else { return } + self.parent.text = editor.string + } + } + + func detachEditorTextDidChangeObserver() { + if let editorTextDidChangeObserver { + NotificationCenter.default.removeObserver(editorTextDidChangeObserver) + self.editorTextDidChangeObserver = nil + } + observedEditor = nil + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> CommandPaletteNativeTextField { + let field = CommandPaletteNativeTextField(frame: .zero) + field.font = .systemFont(ofSize: 13) + field.placeholderString = placeholder + field.setAccessibilityIdentifier("CommandPaletteSearchField") + field.delegate = context.coordinator + field.stringValue = text + field.isEditable = true + field.isSelectable = true + field.isEnabled = true + field.onHandleKeyEvent = { [weak coordinator = context.coordinator] event, editor in + coordinator?.handleKeyEvent(event, editor: editor) ?? false + } + context.coordinator.parentField = field + return field + } + + func updateNSView(_ nsView: CommandPaletteNativeTextField, context: Context) { + context.coordinator.parent = self + context.coordinator.parentField = nsView + nsView.placeholderString = placeholder + + if let editor = nsView.currentEditor() as? NSTextView { + context.coordinator.attachEditorTextDidChangeObserverIfNeeded(editor) + if editor.string != text, !editor.hasMarkedText() { + context.coordinator.isProgrammaticMutation = true + editor.string = text + nsView.stringValue = text + context.coordinator.isProgrammaticMutation = false + } + } else if nsView.stringValue != text { + context.coordinator.detachEditorTextDidChangeObserver() + nsView.stringValue = text + } else { + context.coordinator.detachEditorTextDidChangeObserver() + } + + guard let window = nsView.window else { return } + let firstResponder = window.firstResponder + let isFirstResponder = + firstResponder === nsView || + nsView.currentEditor() != nil || + ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView + + if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true { + context.coordinator.pendingFocusRequest = true + DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in + coordinator?.pendingFocusRequest = nil + guard let coordinator, coordinator.parent.isFocused else { return } + guard let nsView, let window = nsView.window else { return } + let firstResponder = window.firstResponder + let alreadyFocused = + firstResponder === nsView || + nsView.currentEditor() != nil || + ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView + guard !alreadyFocused else { return } + window.makeFirstResponder(nsView) + } + } + } + + static func dismantleNSView(_ nsView: CommandPaletteNativeTextField, coordinator: Coordinator) { + nsView.delegate = nil + nsView.onHandleKeyEvent = nil + coordinator.detachEditorTextDidChangeObserver() + coordinator.parentField = nil + } + } + private func renameInputHintText(target: CommandPaletteRenameTarget) -> String { switch target.kind { case .workspace: @@ -3383,14 +3638,36 @@ struct ContentView: View { } private var commandPaletteListScope: CommandPaletteListScope { - if commandPaletteQuery.hasPrefix(Self.commandPaletteCommandsPrefix) { + Self.commandPaletteListScope(for: commandPaletteQuery) + } + + private var commandPaletteCurrentSearchFingerprint: Int { + commandPaletteEntriesFingerprint( + for: commandPaletteListScope, + includeSurfaces: commandPaletteSwitcherIncludesSurfaceEntries + ) + } + + nonisolated private static func commandPaletteListScope(for query: String) -> CommandPaletteListScope { + if query.hasPrefix(Self.commandPaletteCommandsPrefix) { return .commands } return .switcher } - private var commandPaletteCurrentSearchFingerprint: Int { - commandPaletteEntriesFingerprint(for: commandPaletteListScope) + static func commandPaletteShouldResetVisibleResultsForQueryTransition( + oldQuery: String, + newQuery: String, + hasVisibleResults: Bool + ) -> Bool { + hasVisibleResults && commandPaletteListScope(for: oldQuery) != commandPaletteListScope(for: newQuery) + } + + private var commandPaletteSwitcherIncludesSurfaceEntries: Bool { + Self.commandPaletteSwitcherIncludesSurfaceEntries( + searchAllSurfaces: commandPaletteSearchAllSurfaces, + query: commandPaletteQuery + ) } private var commandPaletteSearchPlaceholder: String { @@ -3398,7 +3675,9 @@ struct ContentView: View { case .commands: return String(localized: "commandPalette.search.commandsPlaceholder", defaultValue: "Type a command") case .switcher: - return String(localized: "commandPalette.search.switcherPlaceholder", defaultValue: "Search workspaces") + return commandPaletteSearchAllSurfaces + ? String(localized: "commandPalette.search.switcherPlaceholderAllSurfaces", defaultValue: "Search workspaces and surfaces") + : String(localized: "commandPalette.search.switcherPlaceholder", defaultValue: "Search workspaces") } } @@ -3407,37 +3686,109 @@ struct ContentView: View { case .commands: return String(localized: "commandPalette.search.commandsEmpty", defaultValue: "No commands match your search.") case .switcher: - return String(localized: "commandPalette.search.switcherEmpty", defaultValue: "No workspaces match your search.") + return commandPaletteSearchAllSurfaces + ? String(localized: "commandPalette.search.switcherEmptyAllSurfaces", defaultValue: "No workspaces or surfaces match your search.") + : String(localized: "commandPalette.search.switcherEmpty", defaultValue: "No workspaces match your search.") } } private var commandPaletteQueryForMatching: String { - switch commandPaletteListScope { + Self.commandPaletteQueryForMatching( + query: commandPaletteQuery, + scope: commandPaletteListScope + ) + } + + nonisolated private static func commandPaletteRefreshQuery( + stateQuery: String, + observedQuery: String? + ) -> String { + observedQuery ?? stateQuery + } + + nonisolated static func commandPaletteRefreshInputsForTests( + stateQuery: String, + observedQuery: String?, + searchAllSurfaces: Bool + ) -> (scope: String, matchingQuery: String, includesSurfaces: Bool) { + let effectiveQuery = commandPaletteRefreshQuery( + stateQuery: stateQuery, + observedQuery: observedQuery + ) + let scope = commandPaletteListScope(for: effectiveQuery) + return ( + scope: scope.rawValue, + matchingQuery: commandPaletteQueryForMatching(query: effectiveQuery, scope: scope), + includesSurfaces: commandPaletteSwitcherIncludesSurfaceEntries( + searchAllSurfaces: searchAllSurfaces, + query: effectiveQuery + ) + ) + } + + nonisolated private static func commandPaletteQueryForMatching( + query: String, + scope: CommandPaletteListScope + ) -> String { + switch scope { case .commands: - let suffix = String(commandPaletteQuery.dropFirst(Self.commandPaletteCommandsPrefix.count)) + let suffix = String(query.dropFirst(Self.commandPaletteCommandsPrefix.count)) return suffix.trimmingCharacters(in: .whitespacesAndNewlines) case .switcher: - return commandPaletteQuery.trimmingCharacters(in: .whitespacesAndNewlines) + return query.trimmingCharacters(in: .whitespacesAndNewlines) } } private func commandPaletteEntries(for scope: CommandPaletteListScope) -> [CommandPaletteCommand] { + commandPaletteEntries( + for: scope, + includeSurfaces: commandPaletteSwitcherIncludesSurfaceEntries + ) + } + + private func commandPaletteEntries( + for scope: CommandPaletteListScope, + includeSurfaces: Bool + ) -> [CommandPaletteCommand] { switch scope { case .commands: return commandPaletteCommands() case .switcher: - return commandPaletteSwitcherEntries() + return commandPaletteSwitcherEntries(includeSurfaces: includeSurfaces) } } - private func refreshCommandPaletteSearchCorpus(force: Bool = false) { - let scope = commandPaletteListScope - let fingerprint = commandPaletteEntriesFingerprint(for: scope) + nonisolated private static func commandPaletteSwitcherIncludesSurfaceEntries( + searchAllSurfaces: Bool, + query: String + ) -> Bool { + let scope = commandPaletteListScope(for: query) + guard scope == .switcher else { return false } + return searchAllSurfaces && !commandPaletteQueryForMatching(query: query, scope: scope).isEmpty + } + + private func refreshCommandPaletteSearchCorpus( + force: Bool = false, + query: String? = nil + ) { + let effectiveQuery = Self.commandPaletteRefreshQuery( + stateQuery: commandPaletteQuery, + observedQuery: query + ) + let scope = Self.commandPaletteListScope(for: effectiveQuery) + let includeSurfaces = Self.commandPaletteSwitcherIncludesSurfaceEntries( + searchAllSurfaces: commandPaletteSearchAllSurfaces, + query: effectiveQuery + ) + let fingerprint = commandPaletteEntriesFingerprint( + for: scope, + includeSurfaces: includeSurfaces + ) guard force || cachedCommandPaletteScope != scope || cachedCommandPaletteFingerprint != fingerprint else { return } - let entries = commandPaletteEntries(for: scope) + let entries = commandPaletteEntries(for: scope, includeSurfaces: includeSurfaces) commandPaletteSearchCommandsByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) let searchCorpus = entries.map { entry in CommandPaletteSearchCorpusEntry( @@ -3645,18 +3996,32 @@ struct ContentView: View { !hasVisibleResultsForScope } - private func scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: Bool = false) { - refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh) + private func scheduleCommandPaletteResultsRefresh( + query: String? = nil, + forceSearchCorpusRefresh: Bool = false + ) { + let effectiveQuery = Self.commandPaletteRefreshQuery( + stateQuery: commandPaletteQuery, + observedQuery: query + ) + let scope = Self.commandPaletteListScope(for: effectiveQuery) + let matchingQuery = Self.commandPaletteQueryForMatching( + query: effectiveQuery, + scope: scope + ) + + refreshCommandPaletteSearchCorpus( + force: forceSearchCorpusRefresh, + query: effectiveQuery + ) commandPaletteSearchRequestID &+= 1 let requestID = commandPaletteSearchRequestID - let query = commandPaletteQueryForMatching - let scope = commandPaletteListScope let fingerprint = cachedCommandPaletteFingerprint let searchCorpus = commandPaletteSearchCorpus let commandsByID = commandPaletteSearchCommandsByID let usageHistory = commandPaletteUsageHistoryByCommandId - let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty + let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(matchingQuery).isEmpty let historyTimestamp = Date().timeIntervalSince1970 commandPalettePendingActivation = nil cancelCommandPaletteSearch() @@ -3665,7 +4030,7 @@ struct ContentView: View { ) { let matches = Self.commandPaletteResolvedSearchMatches( searchCorpus: searchCorpus, - query: query, + query: matchingQuery, usageHistory: usageHistory, queryIsEmpty: queryIsEmpty, historyTimestamp: historyTimestamp @@ -3689,7 +4054,7 @@ struct ContentView: View { refreshPendingCommandPaletteVisibleResults( scope: scope, fingerprint: fingerprint, - query: query, + query: matchingQuery, usageHistory: usageHistory, queryIsEmpty: queryIsEmpty, historyTimestamp: historyTimestamp @@ -3699,7 +4064,7 @@ struct ContentView: View { commandPaletteSearchTask = Task.detached(priority: .userInitiated) { let matches = Self.commandPaletteResolvedSearchMatches( searchCorpus: searchCorpus, - query: query, + query: matchingQuery, usageHistory: usageHistory, queryIsEmpty: queryIsEmpty, historyTimestamp: historyTimestamp, @@ -3709,10 +4074,14 @@ struct ContentView: View { guard !Task.isCancelled else { return } await MainActor.run { + let currentScope = Self.commandPaletteListScope(for: commandPaletteQuery) guard commandPaletteSearchRequestID == requestID, isCommandPalettePresented, - commandPaletteListScope == scope, - commandPaletteQueryForMatching == query, + currentScope == scope, + Self.commandPaletteQueryForMatching( + query: commandPaletteQuery, + scope: currentScope + ) == matchingQuery, cachedCommandPaletteFingerprint == fingerprint else { return } @@ -3752,11 +4121,21 @@ struct ContentView: View { } private func commandPaletteEntriesFingerprint(for scope: CommandPaletteListScope) -> Int { + commandPaletteEntriesFingerprint( + for: scope, + includeSurfaces: commandPaletteSwitcherIncludesSurfaceEntries + ) + } + + private func commandPaletteEntriesFingerprint( + for scope: CommandPaletteListScope, + includeSurfaces: Bool + ) -> Int { switch scope { case .commands: return commandPaletteCommandsFingerprint() case .switcher: - return commandPaletteSwitcherEntriesFingerprint() + return commandPaletteSwitcherEntriesFingerprint(includeSurfaces: includeSurfaces) } } @@ -3767,7 +4146,7 @@ struct ContentView: View { return hasher.finalize() } - private func commandPaletteSwitcherEntriesFingerprint() -> Int { + private func commandPaletteSwitcherEntriesFingerprint(includeSurfaces: Bool) -> Int { let windowContexts = commandPaletteSwitcherWindowContexts() let fingerprintContexts = windowContexts.map { context in CommandPaletteSwitcherFingerprintContext( @@ -3778,7 +4157,25 @@ struct ContentView: View { CommandPaletteSwitcherFingerprintWorkspace( id: workspace.id, displayName: workspaceDisplayName(workspace), - metadata: commandPaletteWorkspaceSearchMetadata(for: workspace) + metadata: commandPaletteWorkspaceSearchMetadata(for: workspace), + surfaces: includeSurfaces + ? commandPaletteOrderedSwitcherPanels(for: workspace).compactMap { panelId in + guard let panel = workspace.panels[panelId] else { return nil } + return CommandPaletteSwitcherFingerprintSurface( + id: panelId, + displayName: panelDisplayName( + workspace: workspace, + panelId: panelId, + fallback: panel.displayTitle + ), + kindLabel: commandPaletteSurfaceKindLabel(for: panel.panelType), + metadata: commandPaletteSurfaceSearchMetadata( + for: workspace, + panelId: panelId + ) + ) + } + : [] ) } ) @@ -3819,20 +4216,24 @@ struct ContentView: View { return CommandPaletteTrailingLabel(text: shortcutHint, style: .shortcut) } - guard commandPaletteListScope == .switcher else { return nil } - if command.id.hasPrefix("switcher.workspace.") { - return CommandPaletteTrailingLabel(text: String(localized: "commandPalette.kind.workspace", defaultValue: "Workspace"), style: .kind) + if let kindLabel = command.kindLabel { + return CommandPaletteTrailingLabel(text: kindLabel, style: .kind) } return nil } - private func commandPaletteSwitcherEntries() -> [CommandPaletteCommand] { + private func commandPaletteSwitcherEntries(includeSurfaces: Bool) -> [CommandPaletteCommand] { let windowContexts = commandPaletteSwitcherWindowContexts() guard !windowContexts.isEmpty else { return [] } var entries: [CommandPaletteCommand] = [] let estimatedCount = windowContexts.reduce(0) { partial, context in - partial + context.tabManager.tabs.count + let workspaceCount = context.tabManager.tabs.count + guard includeSurfaces else { return partial + workspaceCount } + let surfaceCount = context.tabManager.tabs.reduce(0) { count, workspace in + count + commandPaletteOrderedSwitcherPanels(for: workspace).count + } + return partial + workspaceCount + surfaceCount } entries.reserveCapacity(estimatedCount) var nextRank = 0 @@ -3866,6 +4267,7 @@ struct ContentView: View { title: workspaceName, subtitle: commandPaletteSwitcherSubtitle(base: String(localized: "commandPalette.switcher.workspaceLabel", defaultValue: "Workspace"), windowLabel: context.windowLabel), shortcutHint: nil, + kindLabel: String(localized: "commandPalette.kind.workspace", defaultValue: "Workspace"), keywords: workspaceKeywords, dismissOnRun: true, action: { @@ -3878,6 +4280,53 @@ struct ContentView: View { ) ) nextRank += 1 + + guard includeSurfaces else { continue } + + for panelId in commandPaletteOrderedSwitcherPanels(for: workspace) { + guard let panel = workspace.panels[panelId] else { continue } + let surfaceName = panelDisplayName( + workspace: workspace, + panelId: panelId, + fallback: panel.displayTitle + ) + let surfaceKindLabel = commandPaletteSurfaceKindLabel(for: panel.panelType) + let surfaceCommandId = "switcher.surface.\(panelId.uuidString.lowercased())" + let surfaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: [ + "surface", + "tab", + "switch", + "go", + "open", + surfaceName, + workspaceName + ] + commandPaletteSurfaceKeywords(for: panel.panelType) + windowKeywords, + metadata: commandPaletteSurfaceSearchMetadata(for: workspace, panelId: panelId), + detail: .surface + ) + entries.append( + CommandPaletteCommand( + id: surfaceCommandId, + rank: nextRank, + title: surfaceName, + subtitle: commandPaletteSwitcherSubtitle(base: workspaceName, windowLabel: context.windowLabel), + shortcutHint: nil, + kindLabel: surfaceKindLabel, + keywords: surfaceKeywords, + dismissOnRun: true, + action: { + focusCommandPaletteSwitcherSurfaceTarget( + windowId: windowId, + tabManager: windowTabManager, + workspaceId: workspace.id, + panelId: panelId + ) + } + ) + ) + nextRank += 1 + } } } @@ -3959,6 +4408,19 @@ struct ContentView: View { return workspaces } + private func commandPaletteOrderedSwitcherPanels(for workspace: Workspace) -> [UUID] { + let orderedPanelIds = workspace.sidebarOrderedPanelIds() + guard orderedPanelIds.count < workspace.panels.count else { return orderedPanelIds } + + var panelIds = orderedPanelIds + var seen = Set(orderedPanelIds) + for panelId in workspace.panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) + where seen.insert(panelId).inserted { + panelIds.append(panelId) + } + return panelIds + } + private func focusCommandPaletteSwitcherTarget( windowId: UUID, tabManager: TabManager, @@ -3973,6 +4435,18 @@ struct ContentView: View { } } + private func focusCommandPaletteSwitcherSurfaceTarget( + windowId: UUID, + tabManager: TabManager, + workspaceId: UUID, + panelId: UUID + ) { + DispatchQueue.main.async { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + tabManager.focusTab(workspaceId, surfaceId: panelId, suppressFlash: true) + } + } + private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata { // Keep workspace rows coarse and stable for predictable workspace switching queries. let directories = [workspace.currentDirectory] @@ -3985,6 +4459,42 @@ struct ContentView: View { ) } + private func commandPaletteSurfaceSearchMetadata( + for workspace: Workspace, + panelId: UUID + ) -> CommandPaletteSwitcherSearchMetadata { + let directories = [workspace.panelDirectories[panelId]].compactMap { $0 } + let branches = [workspace.panelGitBranches[panelId]?.branch].compactMap { $0 } + let ports = workspace.surfaceListeningPorts[panelId] ?? [] + return CommandPaletteSwitcherSearchMetadata( + directories: directories, + branches: branches, + ports: ports + ) + } + + private func commandPaletteSurfaceKindLabel(for panelType: PanelType) -> String { + switch panelType { + case .terminal: + return String(localized: "commandPalette.kind.terminal", defaultValue: "Terminal") + case .browser: + return String(localized: "commandPalette.kind.browser", defaultValue: "Browser") + case .markdown: + return String(localized: "commandPalette.kind.markdown", defaultValue: "Markdown") + } + } + + private func commandPaletteSurfaceKeywords(for panelType: PanelType) -> [String] { + switch panelType { + case .terminal: + return ["terminal", "shell", "console"] + case .browser: + return ["browser", "web", "page"] + case .markdown: + return ["markdown", "note", "preview"] + } + } + private func commandPaletteCommands() -> [CommandPaletteCommand] { let context = commandPaletteContextSnapshot() let contributions = commandPaletteCommandContributions() @@ -4008,6 +4518,7 @@ struct ContentView: View { title: contribution.title(context), subtitle: contribution.subtitle(context), shortcutHint: commandPaletteShortcutHint(for: contribution, context: context), + kindLabel: nil, keywords: contribution.keywords, dismissOnRun: contribution.dismissOnRun, action: action @@ -5333,6 +5844,13 @@ struct ContentView: View { hasher.combine(workspace.id) hasher.combine(workspace.displayName) combineCommandPaletteSwitcherSearchMetadata(workspace.metadata, into: &hasher) + hasher.combine(workspace.surfaces.count) + for surface in workspace.surfaces { + hasher.combine(surface.id) + hasher.combine(surface.displayName) + hasher.combine(surface.kindLabel) + combineCommandPaletteSwitcherSearchMetadata(surface.metadata, into: &hasher) + } } } return hasher.finalize() @@ -5426,20 +5944,6 @@ struct ContentView: View { 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 @@ -5565,19 +6069,27 @@ struct ContentView: View { } private func openCommandPaletteCommands() { - toggleCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + handleCommandPaletteListRequest(scope: .commands) } private func openCommandPaletteSwitcher() { - toggleCommandPalette(initialQuery: "") + handleCommandPaletteListRequest(scope: .switcher) } - private func toggleCommandPalette(initialQuery: String) { - if isCommandPalettePresented { - dismissCommandPalette() - } else { + private func handleCommandPaletteListRequest(scope: CommandPaletteListScope) { + let initialQuery = (scope == .commands) ? Self.commandPaletteCommandsPrefix : "" + guard isCommandPalettePresented else { presentCommandPalette(initialQuery: initialQuery) + return } + + if case .commands = commandPaletteMode, + commandPaletteListScope == scope { + dismissCommandPalette() + return + } + + resetCommandPaletteListState(initialQuery: initialQuery) } private func openCommandPaletteRenameTabInput() { @@ -11321,6 +11833,7 @@ enum SidebarDropPlanner { draggedTabId: UUID?, targetTabId: UUID?, tabIds: [UUID], + pinnedTabIds: Set, pointerY: CGFloat? = nil, targetHeight: CGFloat? = nil ) -> SidebarDropIndicator? { @@ -11341,16 +11854,27 @@ enum SidebarDropPlanner { insertionPosition = tabIds.count } - let targetIndex = resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count) - guard targetIndex != fromIndex else { return nil } - return indicatorForInsertionPosition(insertionPosition, tabIds: tabIds) + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + let legalTargetIndex = resolvedTargetIndex( + from: fromIndex, + insertionPosition: legalInsertionPosition, + totalCount: tabIds.count + ) + guard legalTargetIndex != fromIndex else { return nil } + return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) } static func targetIndex( draggedTabId: UUID, targetTabId: UUID?, indicator: SidebarDropIndicator?, - tabIds: [UUID] + tabIds: [UUID], + pinnedTabIds: Set ) -> Int? { guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } @@ -11367,7 +11891,13 @@ enum SidebarDropPlanner { insertionPosition = tabIds.count } - return resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count) + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + return resolvedTargetIndex(from: fromIndex, insertionPosition: legalInsertionPosition, totalCount: tabIds.count) } private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator { @@ -11391,6 +11921,28 @@ enum SidebarDropPlanner { return fromIndex < targetIndex ? .bottom : .top } + private static func legalInsertionPosition( + draggedTabId: UUID, + proposedInsertionPosition: Int, + tabIds: [UUID], + pinnedTabIds: Set + ) -> Int { + let clampedInsertion = max(0, min(proposedInsertionPosition, tabIds.count)) + guard !pinnedTabIds.isEmpty else { return clampedInsertion } + + let pinnedCount = tabIds.reduce(into: 0) { count, tabId in + if pinnedTabIds.contains(tabId) { + count += 1 + } + } + guard pinnedCount > 0 else { return clampedInsertion } + + if pinnedTabIds.contains(draggedTabId) { + return min(clampedInsertion, pinnedCount) + } + return max(clampedInsertion, pinnedCount) + } + static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { guard targetHeight > 0 else { return .top } let clampedY = min(max(locationY, 0), targetHeight) @@ -11773,7 +12325,8 @@ private struct SidebarTabDropDelegate: DropDelegate { draggedTabId: draggedTabId, targetTabId: targetTabId, indicator: dropIndicator, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) ) else { #if DEBUG dlog( @@ -11808,10 +12361,12 @@ private struct SidebarTabDropDelegate: DropDelegate { private func updateDropIndicator(for info: DropInfo) { let tabIds = tabManager.tabs.map(\.id) + let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) dropIndicator = SidebarDropPlanner.indicator( draggedTabId: draggedTabId, targetTabId: targetTabId, tabIds: tabIds, + pinnedTabIds: pinnedTabIds, pointerY: targetTabId == nil ? nil : info.location.y, targetHeight: targetRowHeight ) @@ -12302,19 +12857,30 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable { } private struct SidebarBackdrop: View { - @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.18 - @AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000" + @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity + @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex + @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? + @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 + @Environment(\.colorScheme) private var colorScheme var body: some View { let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active - let tintColor = (NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) + let resolvedHex: String = { + if colorScheme == .dark, let dark = sidebarTintHexDark { + return dark + } else if colorScheme == .light, let light = sidebarTintHexLight { + return light + } + return sidebarTintHex + }() + let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) let cornerRadius = CGFloat(max(0, sidebarCornerRadius)) let useLiquidGlass = materialOption?.usesLiquidGlass ?? false let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow @@ -12449,6 +13015,11 @@ enum SidebarStateOption: String, CaseIterable, Identifiable { } } +enum SidebarTintDefaults { + static let hex = "#000000" + static let opacity = 0.18 +} + enum SidebarPresetOption: String, CaseIterable, Identifiable { case nativeSidebar case glassBehind diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 370572ae..13f78f12 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -29,6 +29,13 @@ struct GhosttyConfig { var selectionBackground: NSColor = NSColor(hex: "#57584f")! var selectionForeground: NSColor = NSColor(hex: "#fdfff1")! + // Sidebar appearance + var rawSidebarBackground: String? + var sidebarBackground: NSColor? + var sidebarBackgroundLight: NSColor? + var sidebarBackgroundDark: NSColor? + var sidebarTintOpacity: Double? + // Palette colors (0-15) var palette: [Int: NSColor] = [:] @@ -134,6 +141,54 @@ struct GhosttyConfig { return [] } + mutating func resolveSidebarBackground(preferredColorScheme: ColorSchemePreference) { + guard let raw = rawSidebarBackground else { return } + + let lightResolved = Self.resolveThemeName(from: raw, preferredColorScheme: .light) + let darkResolved = Self.resolveThemeName(from: raw, preferredColorScheme: .dark) + let hasDualMode = lightResolved != darkResolved + + if hasDualMode { + sidebarBackgroundLight = NSColor(hex: lightResolved) + sidebarBackgroundDark = NSColor(hex: darkResolved) + } + + let resolved = Self.resolveThemeName(from: raw, preferredColorScheme: preferredColorScheme) + if let color = NSColor(hex: resolved) { + sidebarBackground = color + } + } + + func applySidebarAppearanceToUserDefaults() { + guard rawSidebarBackground != nil else { + if let opacity = sidebarTintOpacity { + UserDefaults.standard.set(opacity, forKey: "sidebarTintOpacity") + } + return + } + + let defaults = UserDefaults.standard + + if let light = sidebarBackgroundLight { + defaults.set(light.hexString(), forKey: "sidebarTintHexLight") + } else { + defaults.removeObject(forKey: "sidebarTintHexLight") + } + if let dark = sidebarBackgroundDark { + defaults.set(dark.hexString(), forKey: "sidebarTintHexDark") + } else { + defaults.removeObject(forKey: "sidebarTintHexDark") + } + if let color = sidebarBackground { + defaults.set(color.hexString(), forKey: "sidebarTintHex") + } else { + defaults.removeObject(forKey: "sidebarTintHex") + } + if let opacity = sidebarTintOpacity { + defaults.set(opacity, forKey: "sidebarTintOpacity") + } + } + private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig { var config = GhosttyConfig() @@ -161,6 +216,9 @@ struct GhosttyConfig { ) } + config.resolveSidebarBackground(preferredColorScheme: preferredColorScheme) + config.applySidebarAppearanceToUserDefaults() + return config } @@ -240,6 +298,12 @@ struct GhosttyConfig { if let color = NSColor(hex: value) { splitDividerColor = color } + case "sidebar-background": + rawSidebarBackground = value + case "sidebar-tint-opacity": + if let opacity = Double(value) { + sidebarTintOpacity = min(max(opacity, 0), 1) + } default: break } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 94f56895..9caeefb1 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1923,7 +1923,15 @@ class GhosttyApp { let tabId = tabManager.selectedTabId else { return false } - let tabTitle = tabManager.titleForTab(tabId) ?? "Terminal" + // Suppress OSC notifications for workspaces with active Claude hook sessions. + // The hook system manages notifications with proper lifecycle tracking; + // raw OSC notifications would duplicate or outlive the structured hooks. + let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? tabManager + if let workspace = owningManager.tabs.first(where: { $0.id == tabId }), + workspace.agentPIDs["claude_code"] != nil { + return true + } + let tabTitle = owningManager.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody let surfaceId = tabManager.focusedSurfaceId(for: tabId) @@ -2195,7 +2203,13 @@ class GhosttyApp { let actionBody = action.action.desktop_notification.body .flatMap { String(cString: $0) } ?? "" performOnMain { - let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" + // Suppress OSC notifications for workspaces with active Claude hook sessions. + let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager + if let workspace = owningManager?.tabs.first(where: { $0.id == tabId }), + workspace.agentPIDs["claude_code"] != nil { + return + } + let tabTitle = owningManager?.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody TerminalNotificationStore.shared.addNotification( @@ -3872,6 +3886,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { source: "surface.viewDidMoveToWindow" ) applyWindowBackgroundIfActive() + invalidateTextInputCoordinates() } override func viewDidChangeEffectiveAppearance() { @@ -3903,11 +3918,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { CATransaction.commit() } updateSurfaceSize() + invalidateTextInputCoordinates() } override func layout() { super.layout() updateSurfaceSize() + invalidateTextInputCoordinates() } override var isOpaque: Bool { false } @@ -4456,16 +4473,49 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func accessibilitySelectedText() -> String? { - guard let surface = surface else { return nil } + guard let snapshot = readSelectionSnapshot() else { return nil } + return snapshot.string.isEmpty ? nil : snapshot.string + } + + private func readSelectionSnapshot() -> SelectionSnapshot? { + guard let surface else { return nil } var text = ghostty_text_s() guard ghostty_surface_read_selection(surface, &text) else { return nil } defer { ghostty_surface_free_text(surface, &text) } - guard let ptr = text.text, text.text_len > 0 else { return nil } - let selectedData = Data(bytes: ptr, count: Int(text.text_len)) - let selected = String(decoding: selectedData, as: UTF8.self) - return selected.isEmpty ? nil : selected + let selected: String + if let ptr = text.text, text.text_len > 0 { + let selectedData = Data(bytes: ptr, count: Int(text.text_len)) + selected = String(decoding: selectedData, as: UTF8.self) + } else { + selected = "" + } + + return SelectionSnapshot( + range: NSRange(location: Int(text.offset_start), length: Int(text.offset_len)), + string: selected, + topLeft: CGPoint(x: text.tl_px_x, y: text.tl_px_y) + ) + } + + private func visibleDocumentRectInScreenCoordinates() -> NSRect { + let localRect = visibleRect + let windowRect = convert(localRect, to: nil) + guard let window else { return windowRect } + return window.convertToScreen(windowRect) + } + + private func invalidateTextInputCoordinates(selectionChanged: Bool = false) { + guard let inputContext else { return } + inputContext.invalidateCharacterCoordinates() + guard selectionChanged else { return } + + // `textInputClientDidUpdateSelection` is absent from the Xcode 16.2 AppKit SDK + // used by the macOS 14 compatibility lane, so call it dynamically when present. + let updateSelectionSelector = NSSelectorFromString("textInputClientDidUpdateSelection") + guard inputContext.responds(to: updateSelectionSelector) else { return } + _ = inputContext.perform(updateSelectionSelector) } override var acceptsFirstResponder: Bool { true } @@ -4560,6 +4610,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var keyTextAccumulator: [String]? = nil private var markedText = NSMutableAttributedString() private var lastPerformKeyEvent: TimeInterval? + private struct SelectionSnapshot { + let range: NSRange + let string: String + let topLeft: CGPoint + } #if DEBUG // Test-only accessors for keyTextAccumulator to verify CJK IME composition behavior. @@ -5913,7 +5968,9 @@ final class GhosttySurfaceScrollView: NSView { private let keyboardCopyModeBadgeIconView: NSImageView private let keyboardCopyModeBadgeLabel: NSTextField private var searchOverlayHostingView: NSHostingView? + private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem? private var lastSearchOverlayStateID: ObjectIdentifier? + private var searchOverlayMutationGeneration: UInt64 = 0 private var observers: [NSObjectProtocol] = [] private var windowObservers: [NSObjectProtocol] = [] private var isLiveScrolling = false @@ -6300,6 +6357,7 @@ final class GhosttySurfaceScrollView: NSView { #endif observers.forEach { NotificationCenter.default.removeObserver($0) } windowObservers.forEach { NotificationCenter.default.removeObserver($0) } + deferredSearchOverlayMutationWorkItem?.cancel() dropZoneOverlayView.removeFromSuperview() cancelFocusRequest() } @@ -6386,6 +6444,9 @@ final class GhosttySurfaceScrollView: NSView { } _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) _ = setFrameIfNeeded(flashOverlayView, to: bounds) + if let overlay = searchOverlayHostingView { + _ = setFrameIfNeeded(overlay, to: bounds) + } updateNotificationRingPath() updateFlashPath(style: .standardFocus) synchronizeScrollView() @@ -6625,50 +6686,42 @@ final class GhosttySurfaceScrollView: NSView { CATransaction.commit() } - func setSearchOverlay(searchState: TerminalSurface.SearchState?) { - if !Thread.isMainThread { - DispatchQueue.main.async { [weak self] in - self?.setSearchOverlay(searchState: searchState) - } - return + private func cancelDeferredSearchOverlayMutation() { + deferredSearchOverlayMutationWorkItem?.cancel() + deferredSearchOverlayMutationWorkItem = nil + } + + private func scheduleDeferredSearchOverlayMutation( + generation: UInt64, + _ mutation: @escaping () -> Void + ) { + cancelDeferredSearchOverlayMutation() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.searchOverlayMutationGeneration == generation else { return } + self.deferredSearchOverlayMutationWorkItem = nil + mutation() } + deferredSearchOverlayMutationWorkItem = work + DispatchQueue.main.async(execute: work) + } - // Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view. - // SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces. - guard let terminalSurface = surfaceView.terminalSurface, - let searchState else { - let hadOverlay = searchOverlayHostingView != nil - lastSearchOverlayStateID = nil - guard hadOverlay else { return } -#if DEBUG - dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)") -#endif - searchOverlayHostingView?.removeFromSuperview() - searchOverlayHostingView = nil - searchFocusTarget = .searchField - return + private func updateKeyboardCopyModeBadgeZOrder(relativeTo overlay: NSView?) { + guard !keyboardCopyModeBadgeContainerView.isHidden else { return } + if let overlay, overlay.superview === self { + addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) + } else { + addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil) } + } - let searchStateID = ObjectIdentifier(searchState) - if let overlay = searchOverlayHostingView, - lastSearchOverlayStateID == searchStateID, - overlay.superview === self { - if !keyboardCopyModeBadgeContainerView.isHidden { - addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) - } - return - } - - let hadOverlay = searchOverlayHostingView != nil -#if DEBUG - dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")") -#endif - - let tabId = terminalSurface.tabId - let surfaceId = terminalSurface.id - let rootView = SurfaceSearchOverlay( - tabId: tabId, - surfaceId: surfaceId, + private func makeSearchOverlayRootView( + terminalSurface: TerminalSurface, + searchState: TerminalSurface.SearchState + ) -> SurfaceSearchOverlay { + SurfaceSearchOverlay( + tabId: terminalSurface.tabId, + surfaceId: terminalSurface.id, searchState: searchState, onMoveFocusToTerminal: { [weak self] in self?.searchFocusTarget = .terminal @@ -6686,41 +6739,165 @@ final class GhosttySurfaceScrollView: NSView { self?.moveFocus() } ) + } + + private func findEditableSearchField(in view: NSView?) -> NSTextField? { + guard let view else { return nil } + if let field = view as? NSTextField, field.isEditable { + return field + } + for subview in view.subviews { + if let field = findEditableSearchField(in: subview) { + return field + } + } + return nil + } + + private func requestMountedSearchFieldFocus( + generation: UInt64, + force: Bool, + attemptsRemaining: Int = 4 + ) { + guard searchOverlayMutationGeneration == generation else { return } + guard force || searchFocusTarget == .searchField else { return } + guard let overlay = searchOverlayHostingView, + overlay.superview === self, + let window, + window.isKeyWindow else { return } + + guard let field = findEditableSearchField(in: overlay) else { + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in + self?.requestMountedSearchFieldFocus( + generation: generation, + force: force, + attemptsRemaining: attemptsRemaining - 1 + ) + } + return + } + + let firstResponder = window.firstResponder + let alreadyFocused = firstResponder === field || + field.currentEditor() != nil || + ((firstResponder as? NSTextView)?.delegate as? NSTextField) === field + guard !alreadyFocused else { return } + + surfaceView.terminalSurface?.setFocus(false) + let result = window.makeFirstResponder(field) +#if DEBUG + dlog( + "find.mountedFieldFocus surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "result=\(result ? 1 : 0) attemptsRemaining=\(attemptsRemaining) " + + "firstResponder=\(String(describing: window.firstResponder))" + ) +#endif + guard !result, attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in + self?.requestMountedSearchFieldFocus( + generation: generation, + force: force, + attemptsRemaining: attemptsRemaining - 1 + ) + } + } + + func setSearchOverlay(searchState: TerminalSurface.SearchState?) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.setSearchOverlay(searchState: searchState) + } + return + } + + searchOverlayMutationGeneration &+= 1 + let mutationGeneration = searchOverlayMutationGeneration + + // Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view. + // SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces. + guard let terminalSurface = surfaceView.terminalSurface, + let searchState else { + let hadOverlay = searchOverlayHostingView != nil + lastSearchOverlayStateID = nil + searchFocusTarget = .searchField + guard hadOverlay else { + cancelDeferredSearchOverlayMutation() + return + } +#if DEBUG + dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)") +#endif + scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self] in + self?.searchOverlayHostingView?.removeFromSuperview() + self?.searchOverlayHostingView = nil + } + return + } + + let searchStateID = ObjectIdentifier(searchState) + if let overlay = searchOverlayHostingView, + lastSearchOverlayStateID == searchStateID, + overlay.superview === self { + cancelDeferredSearchOverlayMutation() + _ = setFrameIfNeeded(overlay, to: bounds) + updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) + return + } + + let hadOverlay = searchOverlayHostingView != nil +#if DEBUG + dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")") +#endif + + let rootView = makeSearchOverlayRootView( + terminalSurface: terminalSurface, + searchState: searchState + ) if let overlay = searchOverlayHostingView { overlay.rootView = rootView - if overlay.superview !== self { - overlay.removeFromSuperview() - addSubview(overlay) - NSLayoutConstraint.activate([ - overlay.topAnchor.constraint(equalTo: topAnchor), - overlay.bottomAnchor.constraint(equalTo: bottomAnchor), - overlay.leadingAnchor.constraint(equalTo: leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - } - if !keyboardCopyModeBadgeContainerView.isHidden { - addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) - } lastSearchOverlayStateID = searchStateID + if overlay.superview !== self { + scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in + guard let self, let overlay else { return } + overlay.removeFromSuperview() + overlay.frame = self.bounds + overlay.autoresizingMask = [.width, .height] + self.addSubview(overlay) + self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) + self.requestMountedSearchFieldFocus( + generation: mutationGeneration, + force: false + ) + } + return + } + cancelDeferredSearchOverlayMutation() + _ = setFrameIfNeeded(overlay, to: bounds) + updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) return } searchFocusTarget = .searchField let overlay = NSHostingView(rootView: rootView) - overlay.translatesAutoresizingMaskIntoConstraints = false - addSubview(overlay) - NSLayoutConstraint.activate([ - overlay.topAnchor.constraint(equalTo: topAnchor), - overlay.bottomAnchor.constraint(equalTo: bottomAnchor), - overlay.leadingAnchor.constraint(equalTo: leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - if !keyboardCopyModeBadgeContainerView.isHidden { - addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) - } + overlay.frame = bounds + overlay.autoresizingMask = [.width, .height] searchOverlayHostingView = overlay lastSearchOverlayStateID = searchStateID + scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in + guard let self, let overlay else { return } + guard self.searchOverlayHostingView === overlay else { return } + overlay.removeFromSuperview() + overlay.frame = self.bounds + overlay.autoresizingMask = [.width, .height] + self.addSubview(overlay) + self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay) + self.requestMountedSearchFieldFocus( + generation: mutationGeneration, + force: true + ) + } } func syncKeyStateIndicator(text: String?) { @@ -6739,11 +6916,7 @@ final class GhosttySurfaceScrollView: NSView { || subviews.last !== keyboardCopyModeBadgeContainerView keyboardCopyModeBadgeContainerView.isHidden = false if needsReorder { - if let overlay = searchOverlayHostingView { - addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay) - } else { - addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil) - } + updateKeyboardCopyModeBadgeZOrder(relativeTo: searchOverlayHostingView) } return } @@ -7463,6 +7636,17 @@ final class GhosttySurfaceScrollView: NSView { let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" switch searchFocusTarget { case .searchField: + if let firstResponder = window.firstResponder, + isCurrentSurfaceSearchFieldResponder(firstResponder) { + surfaceView.terminalSurface?.setFocus(false) +#if DEBUG + dlog( + "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " + + "reason=alreadyFocused firstResponder=\(String(describing: firstResponder))" + ) +#endif + return + } if let firstResponder = window.firstResponder, isSearchOverlayOrDescendant(firstResponder), !isCurrentSurfaceSearchResponder(firstResponder) { @@ -7666,6 +7850,17 @@ final class GhosttySurfaceScrollView: NSView { return view.isDescendant(of: self) } + private func isCurrentSurfaceSearchFieldResponder(_ responder: NSResponder) -> Bool { + if let editor = responder as? NSTextView, + editor.isFieldEditor, + let editedView = editor.delegate as? NSTextField { + return editedView.isDescendant(of: self) && isSearchOverlayOrDescendant(editedView) + } + + guard let textField = responder as? NSTextField else { return false } + return textField.isDescendant(of: self) && isSearchOverlayOrDescendant(textField) + } + #if DEBUG struct DebugRenderStats { let drawCount: Int @@ -8121,7 +8316,7 @@ extension GhosttyNSView: NSTextInputClient { } func selectedRange() -> NSRange { - return NSRange(location: NSNotFound, length: 0) + readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0) } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { @@ -8149,6 +8344,7 @@ extension GhosttyNSView: NSTextInputClient { // while composing. if keyTextAccumulator == nil { syncPreedit() + invalidateTextInputCoordinates(selectionChanged: true) } } @@ -8167,6 +8363,7 @@ extension GhosttyNSView: NSTextInputClient { if markedText.length > 0 { markedText.mutableString.setString("") syncPreedit() + invalidateTextInputCoordinates(selectionChanged: true) } } @@ -8207,11 +8404,14 @@ extension GhosttyNSView: NSTextInputClient { } func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { - return nil + guard range.length > 0, + let snapshot = readSelectionSnapshot() else { return nil } + actualRange?.pointee = snapshot.range + return NSAttributedString(string: snapshot.string) } func characterIndex(for point: NSPoint) -> Int { - return 0 + return selectedRange().location } func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { @@ -8225,7 +8425,12 @@ extension GhosttyNSView: NSTextInputClient { var w: Double = cellSize.width var h: Double = cellSize.height #if DEBUG - if let override = imePointOverrideForTesting { + if range.length > 0, + range != selectedRange(), + let snapshot = readSelectionSnapshot() { + x = snapshot.topLeft.x - 2 + y = snapshot.topLeft.y + 2 + } else if let override = imePointOverrideForTesting { x = override.x y = override.y w = override.width @@ -8234,11 +8439,21 @@ extension GhosttyNSView: NSTextInputClient { ghostty_surface_ime_point(surface, &x, &y, &w, &h) } #else - if let surface = surface { + if range.length > 0, + range != selectedRange(), + let snapshot = readSelectionSnapshot() { + x = snapshot.topLeft.x - 2 + y = snapshot.topLeft.y + 2 + } else if let surface = surface { ghostty_surface_ime_point(surface, &x, &y, &w, &h) } #endif + if range.length == 0, w > 0 { + // Dictation expects a caret rect for insertion points rather than a box. + w = 0 + } + // Ghostty coordinates are top-left origin; AppKit expects bottom-left. let viewRect = NSRect( x: x, @@ -8250,6 +8465,30 @@ extension GhosttyNSView: NSTextInputClient { return window.convertToScreen(winRect) } + func attributedString() -> NSAttributedString { + if markedText.length > 0 { + return NSAttributedString(attributedString: markedText) + } + if let snapshot = readSelectionSnapshot(), !snapshot.string.isEmpty { + return NSAttributedString(string: snapshot.string) + } + return NSAttributedString(string: "") + } + + func windowLevel() -> Int { + Int(window?.level.rawValue ?? NSWindow.Level.normal.rawValue) + } + + @available(macOS 14.0, *) + var unionRectInVisibleSelectedRange: NSRect { + firstRect(forCharacterRange: selectedRange(), actualRange: nil) + } + + @available(macOS 14.0, *) + var documentVisibleRect: NSRect { + visibleDocumentRectInScreenCoordinates() + } + func insertText(_ string: Any, replacementRange: NSRange) { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b236a2e9..98acf59a 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -5,6 +5,26 @@ import AppKit import Bonsplit import Network import CFNetwork +import SQLite3 +import CryptoKit +#if canImport(CommonCrypto) +import CommonCrypto +#endif +#if canImport(Security) +import Security +#endif + +fileprivate func dedupedCanonicalURLs(_ urls: [URL]) -> [URL] { + var seen = Set() + var result: [URL] = [] + for url in urls { + let canonical = url.standardizedFileURL.resolvingSymlinksInPath().path + if seen.insert(canonical).inserted { + result.append(url) + } + } + return result +} struct BrowserProxyEndpoint: Equatable { let host: String @@ -70,6 +90,7 @@ enum BrowserSearchEngine: String, CaseIterable, Identifiable { case duckduckgo case bing case kagi + case startpage var id: String { rawValue } @@ -79,6 +100,7 @@ enum BrowserSearchEngine: String, CaseIterable, Identifiable { case .duckduckgo: return "DuckDuckGo" case .bing: return "Bing" case .kagi: return "Kagi" + case .startpage: return "Startpage" } } @@ -96,6 +118,8 @@ enum BrowserSearchEngine: String, CaseIterable, Identifiable { components = URLComponents(string: "https://www.bing.com/search") case .kagi: components = URLComponents(string: "https://kagi.com/search") + case .startpage: + components = URLComponents(string: "https://www.startpage.com/do/dsearch") } components?.queryItems = [ @@ -188,6 +212,209 @@ enum BrowserThemeSettings { } } +struct BrowserProfileDefinition: Codable, Hashable, Identifiable, Sendable { + let id: UUID + var displayName: String + let createdAt: Date + let isBuiltInDefault: Bool + + var slug: String { + if isBuiltInDefault { + return "default" + } + + let normalized = displayName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return normalized.isEmpty ? id.uuidString.lowercased() : normalized + } +} + +@MainActor +final class BrowserProfileStore: ObservableObject { + static let shared = BrowserProfileStore() + + private static let profilesDefaultsKey = "browserProfiles.v1" + private static let lastUsedProfileDefaultsKey = "browserProfiles.lastUsed" + private static let builtInDefaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")! + + @Published private(set) var profiles: [BrowserProfileDefinition] = [] + @Published private(set) var lastUsedProfileID: UUID = builtInDefaultProfileID + + private let defaults: UserDefaults + private var dataStores: [UUID: WKWebsiteDataStore] = [:] + private var historyStores: [UUID: BrowserHistoryStore] = [:] + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + load() + } + + var builtInDefaultProfileID: UUID { + Self.builtInDefaultProfileID + } + + var effectiveLastUsedProfileID: UUID { + profileDefinition(id: lastUsedProfileID) != nil ? lastUsedProfileID : Self.builtInDefaultProfileID + } + + func profileDefinition(id: UUID) -> BrowserProfileDefinition? { + profiles.first(where: { $0.id == id }) + } + + func displayName(for id: UUID) -> String { + profileDefinition(id: id)?.displayName + ?? String(localized: "browser.profile.default", defaultValue: "Default") + } + + func createProfile(named rawName: String) -> BrowserProfileDefinition? { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return nil } + let profile = BrowserProfileDefinition( + id: UUID(), + displayName: name, + createdAt: Date(), + isBuiltInDefault: false + ) + profiles.append(profile) + profiles.sort { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + persist() + noteUsed(profile.id) + return profile + } + + func renameProfile(id: UUID, to rawName: String) -> Bool { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, + let index = profiles.firstIndex(where: { $0.id == id }), + !profiles[index].isBuiltInDefault else { + return false + } + profiles[index].displayName = name + profiles.sort { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + persist() + return true + } + + func canRenameProfile(id: UUID) -> Bool { + guard let profile = profileDefinition(id: id) else { return false } + return !profile.isBuiltInDefault + } + + func noteUsed(_ id: UUID) { + guard profileDefinition(id: id) != nil else { return } + if lastUsedProfileID != id { + lastUsedProfileID = id + defaults.set(id.uuidString, forKey: Self.lastUsedProfileDefaultsKey) + } + } + + func websiteDataStore(for profileID: UUID) -> WKWebsiteDataStore { + if profileID == Self.builtInDefaultProfileID { + return .default() + } + if let existing = dataStores[profileID] { + return existing + } + let store = WKWebsiteDataStore(forIdentifier: profileID) + dataStores[profileID] = store + return store + } + + func historyStore(for profileID: UUID) -> BrowserHistoryStore { + if profileID == Self.builtInDefaultProfileID { + return .shared + } + if let existing = historyStores[profileID] { + return existing + } + let store = BrowserHistoryStore(fileURL: historyFileURL(for: profileID)) + historyStores[profileID] = store + return store + } + + func historyFileURL(for profileID: UUID) -> URL? { + if profileID == Self.builtInDefaultProfileID { + return BrowserHistoryStore.defaultHistoryFileURLForCurrentBundle() + } + + let fm = FileManager.default + guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let bundleId = Bundle.main.bundleIdentifier ?? "cmux" + let namespace = BrowserHistoryStore.normalizedBrowserHistoryNamespaceForBundleIdentifier(bundleId) + let profilesDir = appSupport + .appendingPathComponent(namespace, isDirectory: true) + .appendingPathComponent("browser_profiles", isDirectory: true) + .appendingPathComponent(profileID.uuidString.lowercased(), isDirectory: true) + return profilesDir.appendingPathComponent("browser_history.json", isDirectory: false) + } + + func flushPendingSaves() { + BrowserHistoryStore.shared.flushPendingSaves() + for store in historyStores.values { + store.flushPendingSaves() + } + } + + private func load() { + let builtInDefaultProfile = BrowserProfileDefinition( + id: Self.builtInDefaultProfileID, + displayName: String(localized: "browser.profile.default", defaultValue: "Default"), + createdAt: Date(timeIntervalSince1970: 0), + isBuiltInDefault: true + ) + + if let data = defaults.data(forKey: Self.profilesDefaultsKey), + let decoded = try? JSONDecoder().decode([BrowserProfileDefinition].self, from: data), + !decoded.isEmpty { + var resolvedProfiles = decoded.filter { $0.id != Self.builtInDefaultProfileID } + resolvedProfiles.append(builtInDefaultProfile) + profiles = sortedProfiles(resolvedProfiles) + } else { + profiles = [builtInDefaultProfile] + persist() + } + + if let rawLastUsed = defaults.string(forKey: Self.lastUsedProfileDefaultsKey), + let parsed = UUID(uuidString: rawLastUsed), + profileDefinition(id: parsed) != nil { + lastUsedProfileID = parsed + } else { + lastUsedProfileID = Self.builtInDefaultProfileID + defaults.set(lastUsedProfileID.uuidString, forKey: Self.lastUsedProfileDefaultsKey) + } + } + + private func persist() { + let encoder = JSONEncoder() + guard let data = try? encoder.encode(profiles) else { return } + defaults.set(data, forKey: Self.profilesDefaultsKey) + } + + private func sortedProfiles(_ profiles: [BrowserProfileDefinition]) -> [BrowserProfileDefinition] { + profiles.sorted { + if $0.isBuiltInDefault != $1.isBuiltInDefault { + return $0.isBuiltInDefault && !$1.isBuiltInDefault + } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } +} + enum BrowserLinkOpenSettings { static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true @@ -810,6 +1037,100 @@ final class BrowserHistoryStore: ObservableObject { return Array(ranked.prefix(limit)) } + @discardableResult + func mergeImportedEntries(_ importedEntries: [Entry]) -> Int { + loadIfNeeded() + guard !importedEntries.isEmpty else { return 0 } + + var mergedCount = 0 + for imported in importedEntries { + guard let parsedURL = URL(string: imported.url), + let scheme = parsedURL.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + continue + } + + if let host = parsedURL.host?.lowercased() { + let trimmed = host.hasSuffix(".") ? String(host.dropLast()) : host + if !trimmed.contains(".") { continue } + } + + let urlString = parsedURL.absoluteString + guard urlString != "about:blank" else { continue } + let normalizedKey = normalizedHistoryKey(url: parsedURL) + + let importedTitle = imported.title?.trimmingCharacters(in: .whitespacesAndNewlines) + let importedLastVisited = imported.lastVisited + let importedVisitCount = max(1, imported.visitCount) + let importedTypedCount = max(0, imported.typedCount) + let importedLastTypedAt = imported.lastTypedAt + + if let idx = entries.firstIndex(where: { + if $0.url == urlString { return true } + guard let normalizedKey else { return false } + return normalizedHistoryKey(urlString: $0.url) == normalizedKey + }) { + var didMutate = false + if importedLastVisited > entries[idx].lastVisited { + entries[idx].lastVisited = importedLastVisited + didMutate = true + } + if importedVisitCount > entries[idx].visitCount { + entries[idx].visitCount = importedVisitCount + didMutate = true + } + if importedTypedCount > entries[idx].typedCount { + entries[idx].typedCount = importedTypedCount + didMutate = true + } + if let importedLastTypedAt { + if let existingLastTypedAt = entries[idx].lastTypedAt { + if importedLastTypedAt > existingLastTypedAt { + entries[idx].lastTypedAt = importedLastTypedAt + didMutate = true + } + } else { + entries[idx].lastTypedAt = importedLastTypedAt + didMutate = true + } + } + + let existingTitle = entries[idx].title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let incomingTitle = importedTitle ?? "" + if !incomingTitle.isEmpty, + (existingTitle.isEmpty || importedLastVisited >= entries[idx].lastVisited) { + if entries[idx].title != incomingTitle { + entries[idx].title = incomingTitle + didMutate = true + } + } + + if didMutate { + mergedCount += 1 + } + } else { + entries.append(Entry( + id: UUID(), + url: urlString, + title: importedTitle, + lastVisited: importedLastVisited, + visitCount: importedVisitCount, + typedCount: importedTypedCount, + lastTypedAt: importedLastTypedAt + )) + mergedCount += 1 + } + } + + guard mergedCount > 0 else { return 0 } + entries.sort(by: { $0.lastVisited > $1.lastVisited }) + if entries.count > maxEntries { + entries.removeLast(entries.count - maxEntries) + } + scheduleSave() + return mergedCount + } + func clearHistory() { loadIfNeeded() saveTask?.cancel() @@ -1088,6 +1409,14 @@ final class BrowserHistoryStore: ObservableObject { let data = try encoder.encode(snapshot) try data.write(to: fileURL, options: [.atomic]) } + + nonisolated static func defaultHistoryFileURLForCurrentBundle() -> URL? { + defaultHistoryFileURL() + } + + nonisolated static func normalizedBrowserHistoryNamespaceForBundleIdentifier(_ bundleIdentifier: String) -> String { + normalizedBrowserHistoryNamespace(bundleIdentifier: bundleIdentifier) + } } actor BrowserSearchSuggestionService { @@ -1172,6 +1501,12 @@ actor BrowserSearchSuggestionService { URLQueryItem(name: "q", value: query), ] url = c?.url + case .startpage: + var c = URLComponents(string: "https://www.startpage.com/osuggestions") + c?.queryItems = [ + URLQueryItem(name: "q", value: query), + ] + url = c?.url } guard let url else { return [] } @@ -1196,7 +1531,7 @@ actor BrowserSearchSuggestionService { } switch engine { - case .google, .bing, .kagi: + case .google, .bing, .kagi, .startpage: return parseOSJSON(data: data) case .duckduckgo: return parseDuckDuckGo(data: data) @@ -1280,6 +1615,9 @@ final class BrowserPanel: Panel, ObservableObject { /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() + /// Popup windows owned by this panel (for lifecycle cleanup) + private var popupControllers: [BrowserPopupWindowController] = [] + static let telemetryHookBootstrapScriptSource = """ (() => { if (window.__cmuxHooksInstalled) return true; @@ -1419,6 +1757,9 @@ final class BrowserPanel: Panel, ObservableObject { /// The workspace ID this panel belongs to private(set) var workspaceId: UUID + @Published private(set) var profileID: UUID + @Published private(set) var historyStore: BrowserHistoryStore + /// The underlying web view private(set) var webView: WKWebView private var websiteDataStore: WKWebsiteDataStore @@ -1826,6 +2167,14 @@ final class BrowserPanel: Panel, ObservableObject { return String(localized: "browser.newTab", defaultValue: "New tab") } + var profileDisplayName: String { + BrowserProfileStore.shared.displayName(for: profileID) + } + + var usesBuiltInDefaultProfile: Bool { + profileID == BrowserProfileStore.shared.builtInDefaultProfileID + } + private static let portalHostAreaThreshold: CGFloat = 4 private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2 @@ -1984,13 +2333,16 @@ final class BrowserPanel: Panel, ObservableObject { false } - private static func makeWebView(websiteDataStore: WKWebsiteDataStore) -> CmuxWebView { + private static func makeWebView( + profileID: UUID, + websiteDataStore: WKWebsiteDataStore? = nil + ) -> CmuxWebView { let config = WKWebViewConfiguration() config.processPool = BrowserPanel.sharedProcessPool config.mediaTypesRequiringUserActionForPlayback = [] // Ensure browser cookies/storage persist across navigations and launches. // This reduces repeated consent/bot-challenge flows on sites like Google. - config.websiteDataStore = websiteDataStore + config.websiteDataStore = websiteDataStore ?? BrowserProfileStore.shared.websiteDataStore(for: profileID) // Enable developer extras (DevTools) config.preferences.setValue(true, forKey: "developerExtrasEnabled") @@ -2036,6 +2388,9 @@ final class BrowserPanel: Panel, ObservableObject { self?.endDownloadActivity() } } + webView.onContextMenuOpenLinkInNewTab = { [weak self] url in + self?.openLinkInNewTab(url: url) + } webView.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate setupObservers(for: webView) @@ -2049,6 +2404,7 @@ final class BrowserPanel: Panel, ObservableObject { init( workspaceId: UUID, + profileID: UUID? = nil, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil, proxyEndpoint: BrowserProxyEndpoint? = nil, @@ -2057,23 +2413,35 @@ final class BrowserPanel: Panel, ObservableObject { ) { self.id = UUID() self.workspaceId = workspaceId + let requestedProfileID = profileID ?? BrowserProfileStore.shared.effectiveLastUsedProfileID + let resolvedProfileID = BrowserProfileStore.shared.profileDefinition(id: requestedProfileID) != nil + ? requestedProfileID + : BrowserProfileStore.shared.builtInDefaultProfileID + self.profileID = resolvedProfileID + self.historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") self.remoteProxyEndpoint = proxyEndpoint self.usesRemoteWorkspaceProxy = isRemoteWorkspace self.browserThemeMode = BrowserThemeSettings.mode() self.websiteDataStore = isRemoteWorkspace ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? workspaceId) - : .default() + : BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) - let webView = Self.makeWebView(websiteDataStore: websiteDataStore) + let webView = Self.makeWebView( + profileID: resolvedProfileID, + websiteDataStore: websiteDataStore + ) self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } applyRemoteProxyConfigurationIfAvailable() + BrowserProfileStore.shared.noteUsed(resolvedProfileID) // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() navDelegate.didFinish = { webView in - BrowserHistoryStore.shared.recordVisit(url: webView.url, title: webView.title) + Task { @MainActor [weak self] in + self?.historyStore.recordVisit(url: webView.url, title: webView.title) + } Task { @MainActor [weak self] in guard let self, self.isCurrentWebView(webView) else { return } self.refreshFavicon(from: webView) @@ -2132,6 +2500,9 @@ final class BrowserPanel: Panel, ObservableObject { browserUIDelegate.requestNavigation = { [weak self] request, intent in self?.requestNavigation(request, intent: intent) } + browserUIDelegate.openPopup = { [weak self] configuration, windowFeatures in + self?.createFloatingPopup(configuration: configuration, windowFeatures: windowFeatures) + } self.uiDelegate = browserUIDelegate bindWebView(webView) @@ -2222,7 +2593,7 @@ final class BrowserPanel: Panel, ObservableObject { usesRemoteWorkspaceProxy = isRemoteWorkspace let targetStore = isRemoteWorkspace ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? newWorkspaceId) - : .default() + : BrowserProfileStore.shared.websiteDataStore(for: profileID) let needsStoreSwap = webView.configuration.websiteDataStore !== targetStore websiteDataStore = targetStore remoteProxyEndpoint = proxyEndpoint @@ -2238,6 +2609,91 @@ final class BrowserPanel: Panel, ObservableObject { resumePendingRemoteNavigationIfNeeded() } + @discardableResult + func switchToProfile(_ requestedProfileID: UUID) -> Bool { + let resolvedProfileID = BrowserProfileStore.shared.profileDefinition(id: requestedProfileID) != nil + ? requestedProfileID + : BrowserProfileStore.shared.builtInDefaultProfileID + guard resolvedProfileID != profileID else { + BrowserProfileStore.shared.noteUsed(resolvedProfileID) + return false + } + + let previousWebView = webView + let wasRenderable = shouldRenderWebView + let restoreURL = previousWebView.url ?? currentURL + let restoreURLString = restoreURL?.absoluteString + let shouldRestoreURL = wasRenderable && restoreURLString != nil && restoreURLString != blankURLString + let history = sessionNavigationHistorySnapshot() + let historyCurrentURL = preferredURLStringForOmnibar() + let desiredZoom = max(minPageZoom, min(maxPageZoom, previousWebView.pageZoom)) + let restoreDeveloperTools = preferredDeveloperToolsVisible || isDeveloperToolsVisible() + + invalidateSearchFocusRequests(reason: "profileSwitch") + searchState = nil + + _ = hideDeveloperTools() + cancelDeveloperToolsRestoreRetry() + + webViewObservers.removeAll() + webViewCancellables.removeAll() + faviconTask?.cancel() + faviconTask = nil + faviconRefreshGeneration &+= 1 + BrowserWindowPortalRegistry.detach(webView: previousWebView) + previousWebView.stopLoading() + previousWebView.navigationDelegate = nil + previousWebView.uiDelegate = nil + if let previousCmuxWebView = previousWebView as? CmuxWebView { + previousCmuxWebView.onContextMenuDownloadStateChanged = nil + } + + profileID = resolvedProfileID + historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) + BrowserProfileStore.shared.noteUsed(resolvedProfileID) + + if !usesRemoteWorkspaceProxy { + websiteDataStore = BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) + } + + let replacement = Self.makeWebView( + profileID: resolvedProfileID, + websiteDataStore: websiteDataStore + ) + replacement.pageZoom = desiredZoom + webViewInstanceID = UUID() + webView = replacement + currentURL = restoreURL + shouldRenderWebView = wasRenderable + + bindWebView(replacement) + applyBrowserThemeModeIfNeeded() + + if !history.backHistoryURLStrings.isEmpty || !history.forwardHistoryURLStrings.isEmpty { + restoreSessionNavigationHistory( + backHistoryURLStrings: history.backHistoryURLStrings, + forwardHistoryURLStrings: history.forwardHistoryURLStrings, + currentURLString: historyCurrentURL + ) + } + + if shouldRestoreURL, let restoreURL { + navigateWithoutInsecureHTTPPrompt( + to: restoreURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + } else { + refreshNavigationAvailability() + } + + if restoreDeveloperTools { + requestDeveloperToolsRefreshAfterNextAttach(reason: "profile_switch") + } + + return true + } + func triggerFlash() { guard NotificationPaneFlashSettings.isEnabled() else { return } focusFlashToken &+= 1 @@ -2399,7 +2855,10 @@ final class BrowserPanel: Panel, ObservableObject { oldCmuxWebView.onContextMenuDownloadStateChanged = nil } - let replacement = Self.makeWebView(websiteDataStore: websiteDataStore) + let replacement = Self.makeWebView( + profileID: profileID, + websiteDataStore: websiteDataStore + ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() webView = replacement @@ -2484,6 +2943,17 @@ final class BrowserPanel: Panel, ObservableObject { // Ensure we don't keep a hidden WKWebView (or its content view) as first responder while // bonsplit/SwiftUI reshuffles views during close. unfocus() + + // Snapshot first: popup close unregisters itself from popupControllers. + let popupsToClose = popupControllers + popupControllers.removeAll() + + // Close all owned popup windows before tearing down delegates + for popup in popupsToClose { + popup.closeAllChildPopups() + popup.closePopup() + } + webView.stopLoading() webView.navigationDelegate = nil webView.uiDelegate = nil @@ -2495,6 +2965,25 @@ final class BrowserPanel: Panel, ObservableObject { faviconTask = nil } + // MARK: - Popup window management + + func createFloatingPopup( + configuration: WKWebViewConfiguration, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + let controller = BrowserPopupWindowController( + configuration: configuration, + windowFeatures: windowFeatures, + openerPanel: self + ) + popupControllers.append(controller) + return controller.webView + } + + func removePopupController(_ controller: BrowserPopupWindowController) { + popupControllers.removeAll { $0 === controller } + } + private func refreshFavicon(from webView: WKWebView) { faviconTask?.cancel() faviconTask = nil @@ -2884,7 +3373,7 @@ final class BrowserPanel: Panel, ObservableObject { webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true if recordTypedNavigation { - BrowserHistoryStore.shared.recordTypedNavigation(url: originalURL) + historyStore.recordTypedNavigation(url: originalURL) } navigationDelegate?.lastAttemptedURL = originalURL browserLoadRequest(effectiveRequest, in: webView) @@ -3165,7 +3654,10 @@ extension BrowserPanel { oldCmuxWebView.onContextMenuDownloadStateChanged = nil } - let replacement = Self.makeWebView(websiteDataStore: websiteDataStore) + let replacement = Self.makeWebView( + profileID: profileID, + websiteDataStore: websiteDataStore + ) webViewInstanceID = UUID() webView = replacement shouldRenderWebView = false @@ -3299,6 +3791,7 @@ extension BrowserPanel { inPane: paneId, url: url, focus: true, + preferredProfileID: profileID, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) #if DEBUG @@ -4780,7 +5273,7 @@ private extension NSObject { /// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI /// during WebKit callbacks), then showing NSSavePanel after the download finishes. -private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { +class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private struct DownloadState { let tempURL: URL let suggestedFilename: String @@ -4938,6 +5431,33 @@ func browserNavigationShouldOpenInNewTab( return false } +func browserNavigationShouldCreatePopup( + navigationType: WKNavigationType, + modifierFlags: NSEvent.ModifierFlags, + buttonNumber: Int, + hasRecentMiddleClickIntent: Bool = false, + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, + currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber +) -> Bool { + let isUserNewTab = browserNavigationShouldOpenInNewTab( + navigationType: navigationType, + modifierFlags: modifierFlags, + buttonNumber: buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent, + currentEventType: currentEventType, + currentEventButtonNumber: currentEventButtonNumber + ) + return navigationType == .other && !isUserNewTab +} + +func browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: WKNavigationType +) -> Bool { + // Scripted popups rely on WKUIDelegate.createWebViewWith returning a live + // web view so window.opener/postMessage remain intact across OAuth flows. + navigationType != .other +} + private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? @@ -5175,8 +5695,13 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } - // target=_blank or window.open() — open in a new tab. + // target=_blank link navigations should open in a new tab. + // Scripted popups (navigationType == .other) are handled in + // WKUIDelegate.createWebViewWith so OAuth opener linkage survives. if navigationAction.targetFrame == nil, + browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: navigationAction.navigationType + ), let url = navigationAction.request.url { #if DEBUG dlog("browser.nav.decidePolicy.action kind=openInNewTabFromNilTarget url=\(url.absoluteString)") @@ -5267,6 +5792,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { private class BrowserUIDelegate: NSObject, WKUIDelegate { var openInNewTab: ((URL) -> Void)? var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? + var openPopup: ((WKWebViewConfiguration, WKWindowFeatures) -> WKWebView?)? private func javaScriptDialogTitle(for webView: WKWebView) -> String { if let absolute = webView.url?.absoluteString, !absolute.isEmpty { @@ -5287,17 +5813,17 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { completion(alert.runModal()) } - /// Returning nil tells WebKit not to open a new window. - /// createWebViewWith is only called when the page requests a new window - /// (window.open(), target=_blank, etc.). Always open in a new tab. + /// Called when the page requests a new window (window.open(), target=_blank, etc.). + /// + /// Returns a live popup WKWebView created with WebKit's supplied configuration + /// to preserve popup browsing-context semantics (window.opener, postMessage). + /// Falls back to new-tab behavior only if popup creation is unavailable. func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { - // createWebViewWith is only called when the page requests a new window, - // so always treat as new-tab intent regardless of modifiers/button. #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" @@ -5305,21 +5831,45 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { dlog( "browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + - "eventType=\(currentEventType) eventButton=\(currentEventButton) " + - "openInNewTab=1" + "eventType=\(currentEventType) eventButton=\(currentEventButton)" ) #endif - if let url = navigationAction.request.url { - if browserShouldOpenURLExternally(url) { - let opened = NSWorkspace.shared.open(url) - if !opened { - NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) - } - #if DEBUG - dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") - #endif - return nil + // External URL schemes → hand off to macOS, don't create a popup + if let url = navigationAction.request.url, + browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) } + #if DEBUG + dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif + return nil + } + + // Classifier: only scripted requests (window.open()) get popup windows. + // User-initiated actions (link clicks, context menu "Open Link in New Tab", + // Cmd+click, middle-click) fall through to existing new-tab behavior. + // + // WebKit sometimes delivers .other for Cmd+click / middle-click, so we + // reuse browserNavigationShouldOpenInNewTab to recover user intent before + // treating .other as a scripted popup. + let isScriptedPopup = browserNavigationShouldCreatePopup( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: CmuxWebView.hasRecentMiddleClickIntent(for: webView) + ) + + if isScriptedPopup, let popupWebView = openPopup?(configuration, windowFeatures) { +#if DEBUG + dlog("browser.nav.createWebView.action kind=popup") +#endif + return popupWebView + } + + // Fallback: open in new tab (no opener linkage) + if let url = navigationAction.request.url { if let requestNavigation { let intent: BrowserInsecureHTTPNavigationIntent = .newTab #if DEBUG @@ -5423,3 +5973,3685 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { } } } + +// MARK: - Browser Data Import + +enum BrowserImportScope: String, CaseIterable, Identifiable { + case cookiesOnly + case historyOnly + case cookiesAndHistory + case everything + + var id: String { rawValue } + + var displayName: String { + switch self { + case .cookiesOnly: + return String(localized: "browser.import.scope.cookiesOnly", defaultValue: "Cookies only") + case .historyOnly: + return String(localized: "browser.import.scope.historyOnly", defaultValue: "History only") + case .cookiesAndHistory: + return String(localized: "browser.import.scope.cookiesAndHistory", defaultValue: "Cookies + history") + case .everything: + return String(localized: "browser.import.scope.everything", defaultValue: "Everything") + } + } + + var includesCookies: Bool { + switch self { + case .cookiesOnly, .cookiesAndHistory, .everything: + return true + case .historyOnly: + return false + } + } + + var includesHistory: Bool { + switch self { + case .cookiesOnly: + return false + case .historyOnly, .cookiesAndHistory, .everything: + return true + } + } + + static func fromSelection( + includeCookies: Bool, + includeHistory: Bool, + includeAdditionalData: Bool + ) -> BrowserImportScope? { + if includeAdditionalData { + return .everything + } + guard includeCookies || includeHistory else { return nil } + if includeCookies && includeHistory { + return .cookiesAndHistory + } + if includeCookies { + return .cookiesOnly + } + return .historyOnly + } +} + +enum BrowserImportEngineFamily: String, Hashable { + case chromium + case firefox + case webkit +} + +struct InstalledBrowserProfile: Identifiable, Hashable { + let displayName: String + let rootURL: URL + let isDefault: Bool + + var id: String { + rootURL.standardizedFileURL.resolvingSymlinksInPath().path + } +} + +struct BrowserImportBrowserDescriptor: Hashable { + let id: String + let displayName: String + let family: BrowserImportEngineFamily + let tier: Int + let bundleIdentifiers: [String] + let appNames: [String] + let dataRootRelativePaths: [String] + let dataArtifactRelativePaths: [String] + let supportsDataOnlyDetection: Bool +} + +struct InstalledBrowserCandidate: Identifiable, Hashable { + let descriptor: BrowserImportBrowserDescriptor + let resolvedFamily: BrowserImportEngineFamily + let homeDirectoryURL: URL + let appURL: URL? + let dataRootURL: URL? + let profiles: [InstalledBrowserProfile] + let detectionSignals: [String] + let detectionScore: Int + + var id: String { descriptor.id } + var displayName: String { descriptor.displayName } + var family: BrowserImportEngineFamily { resolvedFamily } + var profileURLs: [URL] { profiles.map(\.rootURL) } +} + +enum InstalledBrowserDetector { + typealias BundleLookup = (String) -> URL? + + static let allBrowserDescriptors: [BrowserImportBrowserDescriptor] = [ + BrowserImportBrowserDescriptor( + id: "safari", + displayName: "Safari", + family: .webkit, + tier: 1, + bundleIdentifiers: ["com.apple.Safari"], + appNames: ["Safari.app"], + dataRootRelativePaths: ["Library/Safari"], + dataArtifactRelativePaths: [ + "Library/Safari/History.db", + "Library/Cookies/Cookies.binarycookies", + ], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "google-chrome", + displayName: "Google Chrome", + family: .chromium, + tier: 1, + bundleIdentifiers: ["com.google.Chrome"], + appNames: ["Google Chrome.app"], + dataRootRelativePaths: ["Library/Application Support/Google/Chrome"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "firefox", + displayName: "Firefox", + family: .firefox, + tier: 1, + bundleIdentifiers: ["org.mozilla.firefox"], + appNames: ["Firefox.app"], + dataRootRelativePaths: ["Library/Application Support/Firefox"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "arc", + displayName: "Arc", + family: .chromium, + tier: 1, + bundleIdentifiers: ["company.thebrowser.Browser", "company.thebrowser.arc"], + appNames: ["Arc.app"], + dataRootRelativePaths: ["Library/Application Support/Arc"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "brave", + displayName: "Brave", + family: .chromium, + tier: 1, + bundleIdentifiers: ["com.brave.Browser"], + appNames: ["Brave Browser.app"], + dataRootRelativePaths: ["Library/Application Support/BraveSoftware/Brave-Browser"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "microsoft-edge", + displayName: "Microsoft Edge", + family: .chromium, + tier: 1, + bundleIdentifiers: ["com.microsoft.edgemac", "com.microsoft.Edge"], + appNames: ["Microsoft Edge.app"], + dataRootRelativePaths: ["Library/Application Support/Microsoft Edge"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "zen", + displayName: "Zen Browser", + family: .firefox, + tier: 2, + bundleIdentifiers: ["app.zen-browser.zen", "app.zen-browser.Zen"], + appNames: ["Zen Browser.app", "Zen.app"], + dataRootRelativePaths: ["Library/Application Support/Zen", "Library/Application Support/zen"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "vivaldi", + displayName: "Vivaldi", + family: .chromium, + tier: 2, + bundleIdentifiers: ["com.vivaldi.Vivaldi"], + appNames: ["Vivaldi.app"], + dataRootRelativePaths: ["Library/Application Support/Vivaldi"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "opera", + displayName: "Opera", + family: .chromium, + tier: 2, + bundleIdentifiers: ["com.operasoftware.Opera"], + appNames: ["Opera.app"], + dataRootRelativePaths: [ + "Library/Application Support/com.operasoftware.Opera", + "Library/Application Support/Opera", + ], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "opera-gx", + displayName: "Opera GX", + family: .chromium, + tier: 2, + bundleIdentifiers: ["com.operasoftware.OperaGX"], + appNames: ["Opera GX.app"], + dataRootRelativePaths: [ + "Library/Application Support/com.operasoftware.OperaGX", + "Library/Application Support/Opera GX Stable", + ], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "orion", + displayName: "Orion", + family: .webkit, + tier: 2, + bundleIdentifiers: ["com.kagi.kagimacOS", "com.kagi.kagimacos", "com.kagi.orion"], + appNames: ["Orion.app"], + dataRootRelativePaths: ["Library/Application Support/Orion"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "dia", + displayName: "Dia", + family: .chromium, + tier: 2, + bundleIdentifiers: ["company.thebrowser.Dia", "company.thebrowser.dia"], + appNames: ["Dia.app"], + dataRootRelativePaths: ["Library/Application Support/Dia"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "perplexity-comet", + displayName: "Perplexity Comet", + family: .chromium, + tier: 3, + bundleIdentifiers: ["ai.perplexity.comet"], + appNames: ["Perplexity Comet.app", "Comet.app"], + dataRootRelativePaths: ["Library/Application Support/Comet"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "floorp", + displayName: "Floorp", + family: .firefox, + tier: 3, + bundleIdentifiers: ["one.ablaze.floorp"], + appNames: ["Floorp.app"], + dataRootRelativePaths: ["Library/Application Support/Floorp"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "waterfox", + displayName: "Waterfox", + family: .firefox, + tier: 3, + bundleIdentifiers: ["net.waterfox.waterfox"], + appNames: ["Waterfox.app"], + dataRootRelativePaths: ["Library/Application Support/Waterfox"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "sigmaos", + displayName: "SigmaOS", + family: .chromium, + tier: 3, + bundleIdentifiers: ["com.feralcat.sigmaos"], + appNames: ["SigmaOS.app"], + dataRootRelativePaths: ["Library/Application Support/SigmaOS"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "sidekick", + displayName: "Sidekick", + family: .chromium, + tier: 3, + bundleIdentifiers: ["com.meetsidekick.Sidekick", "com.pushplaylabs.sidekick"], + appNames: ["Sidekick.app"], + dataRootRelativePaths: ["Library/Application Support/Sidekick"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "helium", + displayName: "Helium", + family: .chromium, + tier: 3, + bundleIdentifiers: ["net.imput.helium", "com.jadenGeller.Helium", "com.jaden.geller.helium"], + appNames: ["Helium.app"], + dataRootRelativePaths: [ + "Library/Application Support/net.imput.helium", + "Library/Application Support/Helium", + ], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "atlas", + displayName: "Atlas", + family: .chromium, + tier: 3, + bundleIdentifiers: ["com.atlas.browser"], + appNames: ["Atlas.app"], + dataRootRelativePaths: ["Library/Application Support/Atlas"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "ladybird", + displayName: "Ladybird", + family: .webkit, + tier: 3, + bundleIdentifiers: ["org.ladybird.Browser", "org.serenityos.ladybird"], + appNames: ["Ladybird.app"], + dataRootRelativePaths: ["Library/Application Support/Ladybird"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "chromium", + displayName: "Chromium", + family: .chromium, + tier: 3, + bundleIdentifiers: ["org.chromium.Chromium"], + appNames: ["Chromium.app"], + dataRootRelativePaths: ["Library/Application Support/Chromium"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: true + ), + BrowserImportBrowserDescriptor( + id: "ungoogled-chromium", + displayName: "Ungoogled Chromium", + family: .chromium, + tier: 3, + bundleIdentifiers: ["org.chromium.ungoogled"], + appNames: ["Ungoogled Chromium.app"], + dataRootRelativePaths: ["Library/Application Support/Chromium"], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: false + ), + ] + + static func detectInstalledBrowsers( + homeDirectoryURL: URL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true), + bundleLookup: BundleLookup? = nil, + applicationSearchDirectories: [URL]? = nil, + fileManager: FileManager = .default + ) -> [InstalledBrowserCandidate] { + let lookup = bundleLookup ?? { bundleIdentifier in + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + } + let appSearchDirectories = applicationSearchDirectories ?? defaultApplicationSearchDirectories(homeDirectoryURL: homeDirectoryURL) + + let candidates = allBrowserDescriptors.compactMap { descriptor -> InstalledBrowserCandidate? in + let appDetection = detectApplication( + descriptor: descriptor, + appSearchDirectories: appSearchDirectories, + bundleLookup: lookup, + fileManager: fileManager + ) + + let dataDetection = detectData( + descriptor: descriptor, + homeDirectoryURL: homeDirectoryURL, + appBundleIdentifier: appDetection.bundleIdentifier, + fileManager: fileManager + ) + + if appDetection.url == nil, + !descriptor.supportsDataOnlyDetection { + return nil + } + + let hasData = dataDetection.dataRootURL != nil || !dataDetection.profiles.isEmpty || !dataDetection.artifactHits.isEmpty + guard appDetection.url != nil || hasData else { + return nil + } + + var score = 0 + if appDetection.url != nil { + score += 80 + } + if dataDetection.dataRootURL != nil { + score += 24 + } + score += min(24, dataDetection.profiles.count * 6) + score += min(16, dataDetection.artifactHits.count * 4) + + var signals: [String] = [] + signals.append(contentsOf: appDetection.signals) + if let root = dataDetection.dataRootURL { + signals.append("data:\(root.lastPathComponent)") + } + if !dataDetection.profiles.isEmpty { + signals.append("profiles:\(dataDetection.profiles.count)") + } + if !dataDetection.artifactHits.isEmpty { + signals.append(contentsOf: dataDetection.artifactHits.map { "artifact:\($0)" }) + } + + return InstalledBrowserCandidate( + descriptor: descriptor, + resolvedFamily: dataDetection.family, + homeDirectoryURL: homeDirectoryURL, + appURL: appDetection.url, + dataRootURL: dataDetection.dataRootURL, + profiles: dataDetection.profiles, + detectionSignals: signals, + detectionScore: score + ) + } + + return candidates.sorted { lhs, rhs in + if lhs.detectionScore != rhs.detectionScore { + return lhs.detectionScore > rhs.detectionScore + } + if lhs.descriptor.tier != rhs.descriptor.tier { + return lhs.descriptor.tier < rhs.descriptor.tier + } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + } + + static func summaryText(for browsers: [InstalledBrowserCandidate], limit: Int = 4) -> String { + guard !browsers.isEmpty else { + return String( + localized: "browser.import.detected.none", + defaultValue: "No supported browsers detected." + ) + } + let names = browsers.map(\.displayName) + if names.count <= limit { + return String( + format: String( + localized: "browser.import.detected.all", + defaultValue: "Detected: %@." + ), + names.joined(separator: ", ") + ) + } + let shown = names.prefix(limit).joined(separator: ", ") + let remaining = names.count - limit + if remaining == 1 { + return String( + format: String( + localized: "browser.import.detected.more.one", + defaultValue: "Detected: %@, +1 more." + ), + shown + ) + } + return String( + format: String( + localized: "browser.import.detected.more.other", + defaultValue: "Detected: %@, +%ld more." + ), + shown, + remaining + ) + } + + private static func detectApplication( + descriptor: BrowserImportBrowserDescriptor, + appSearchDirectories: [URL], + bundleLookup: BundleLookup, + fileManager: FileManager + ) -> (url: URL?, signals: [String], bundleIdentifier: String?) { + for knownBundleIdentifier in descriptor.bundleIdentifiers { + if let appURL = bundleLookup(knownBundleIdentifier) { + return (appURL, ["bundle:\(knownBundleIdentifier)"], bundleIdentifier(for: appURL) ?? knownBundleIdentifier) + } + } + + for appName in descriptor.appNames { + for directory in appSearchDirectories { + let appURL = directory.appendingPathComponent(appName, isDirectory: true) + if fileManager.fileExists(atPath: appURL.path) { + return (appURL, ["app:\(appName)"], bundleIdentifier(for: appURL)) + } + } + } + + return (nil, [], nil) + } + + private static func detectData( + descriptor: BrowserImportBrowserDescriptor, + homeDirectoryURL: URL, + appBundleIdentifier: String?, + fileManager: FileManager + ) -> (dataRootURL: URL?, family: BrowserImportEngineFamily, profiles: [InstalledBrowserProfile], artifactHits: [String]) { + var bestRootURL: URL? + var bestFamily = descriptor.family + var bestProfiles: [InstalledBrowserProfile] = [] + var bestArtifacts: [String] = [] + let candidateRootPaths = candidateDataRootRelativePaths( + descriptor: descriptor, + appBundleIdentifier: appBundleIdentifier + ) + + for relativePath in candidateRootPaths { + let rootURL = homeDirectoryURL.appendingPathComponent(relativePath, isDirectory: true) + guard fileManager.fileExists(atPath: rootURL.path) else { continue } + + let detectedProfiles = detectProfiles( + descriptor: descriptor, + rootURL: rootURL, + homeDirectoryURL: homeDirectoryURL, + fileManager: fileManager + ) + + let score = scoreProfileDetection( + family: detectedProfiles.family, + profiles: detectedProfiles.profiles, + preferredFamily: descriptor.family + ) + 8 + let currentScore = scoreProfileDetection( + family: bestFamily, + profiles: bestProfiles, + preferredFamily: descriptor.family + ) + (bestRootURL == nil ? 0 : 8) + if score > currentScore { + bestRootURL = rootURL + bestFamily = detectedProfiles.family + bestProfiles = detectedProfiles.profiles + } + } + + var artifactHits: [String] = [] + for relativePath in descriptor.dataArtifactRelativePaths { + let artifactURL = homeDirectoryURL.appendingPathComponent(relativePath, isDirectory: false) + if fileManager.fileExists(atPath: artifactURL.path) { + artifactHits.append(artifactURL.lastPathComponent) + } + } + + if !artifactHits.isEmpty { + bestArtifacts = artifactHits + if bestRootURL == nil, + let rootPath = candidateRootPaths.first { + let rootURL = homeDirectoryURL.appendingPathComponent(rootPath, isDirectory: true) + if fileManager.fileExists(atPath: rootURL.path) { + bestRootURL = rootURL + } + } + } + + if bestProfiles.isEmpty, let bestRootURL { + bestProfiles = [ + InstalledBrowserProfile( + displayName: String(localized: "browser.profile.default", defaultValue: "Default"), + rootURL: bestRootURL, + isDefault: true + ) + ] + } + + return ( + dataRootURL: bestRootURL, + family: bestFamily, + profiles: sortProfiles(dedupedProfiles(bestProfiles)), + artifactHits: bestArtifacts + ) + } + + private static func detectProfiles( + descriptor: BrowserImportBrowserDescriptor, + rootURL: URL, + homeDirectoryURL: URL, + fileManager: FileManager + ) -> (family: BrowserImportEngineFamily, profiles: [InstalledBrowserProfile]) { + let candidates: [(BrowserImportEngineFamily, [InstalledBrowserProfile])] = [ + (.chromium, chromiumProfiles(rootURL: rootURL, fileManager: fileManager)), + (.firefox, firefoxProfiles(rootURL: rootURL, fileManager: fileManager)), + (.webkit, webKitProfiles( + descriptor: descriptor, + rootURL: rootURL, + homeDirectoryURL: homeDirectoryURL, + fileManager: fileManager + )), + ] + + return candidates.max { lhs, rhs in + let lhsScore = scoreProfileDetection( + family: lhs.0, + profiles: lhs.1, + preferredFamily: descriptor.family + ) + let rhsScore = scoreProfileDetection( + family: rhs.0, + profiles: rhs.1, + preferredFamily: descriptor.family + ) + if lhsScore != rhsScore { + return lhsScore < rhsScore + } + return lhs.0.rawValue > rhs.0.rawValue + } ?? (descriptor.family, []) + } + + private static func bundleIdentifier(for appURL: URL) -> String? { + Bundle(url: appURL)?.bundleIdentifier + } + + private static func candidateDataRootRelativePaths( + descriptor: BrowserImportBrowserDescriptor, + appBundleIdentifier: String? + ) -> [String] { + var result: [String] = [] + var seen = Set() + + func append(_ relativePath: String) { + if seen.insert(relativePath).inserted { + result.append(relativePath) + } + } + + for relativePath in descriptor.dataRootRelativePaths { + append(relativePath) + } + + let bundleIdentifiers = [appBundleIdentifier].compactMap { $0 } + descriptor.bundleIdentifiers + for bundleIdentifier in bundleIdentifiers { + append("Library/Application Support/\(bundleIdentifier)") + append("Library/Containers/\(bundleIdentifier)/Data/Library/Application Support/\(bundleIdentifier)") + } + + return result + } + + private static func scoreProfileDetection( + family: BrowserImportEngineFamily, + profiles: [InstalledBrowserProfile], + preferredFamily: BrowserImportEngineFamily + ) -> Int { + var score = profiles.count * 10 + if family == preferredFamily { + score += 3 + } + if profiles.contains(where: \.isDefault) { + score += 1 + } + return score + } + + private static func chromiumProfiles( + rootURL: URL, + fileManager: FileManager + ) -> [InstalledBrowserProfile] { + let nameMap = chromiumProfileNameMap(rootURL: rootURL) + var profiles: [InstalledBrowserProfile] = [] + if looksLikeChromiumProfile(rootURL: rootURL, fileManager: fileManager) { + profiles.append( + InstalledBrowserProfile( + displayName: chromiumProfileDisplayName( + directoryName: rootURL.lastPathComponent, + nameMap: nameMap, + isDefault: true + ), + rootURL: rootURL, + isDefault: true + ) + ) + } + + let children = (try? fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + + for child in children { + guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } + let name = child.lastPathComponent + let isLikelyProfile = + name == "Default" || + name.hasPrefix("Profile ") || + name.hasPrefix("Guest Profile") || + name.hasPrefix("Person ") || + nameMap[name] != nil + if isLikelyProfile && looksLikeChromiumProfile(rootURL: child, fileManager: fileManager) { + profiles.append( + InstalledBrowserProfile( + displayName: chromiumProfileDisplayName( + directoryName: name, + nameMap: nameMap, + isDefault: name == "Default" + ), + rootURL: child, + isDefault: name == "Default" + ) + ) + } + } + + return sortProfiles(dedupedProfiles(profiles)) + } + + private static func firefoxProfiles( + rootURL: URL, + fileManager: FileManager + ) -> [InstalledBrowserProfile] { + var profiles = firefoxProfilesFromINI(rootURL: rootURL, fileManager: fileManager) + + let likelyProfileRoots = [ + rootURL.appendingPathComponent("Profiles", isDirectory: true), + rootURL, + ] + + for directory in likelyProfileRoots where fileManager.fileExists(atPath: directory.path) { + let children = (try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + for child in children { + guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } + if looksLikeFirefoxProfile(rootURL: child, fileManager: fileManager) { + let directoryName = child.lastPathComponent + profiles.append( + InstalledBrowserProfile( + displayName: directoryName, + rootURL: child, + isDefault: directoryName.localizedCaseInsensitiveContains("default") + ) + ) + } + } + } + + return sortProfiles(dedupedProfiles(profiles)) + } + + private static func firefoxProfilesFromINI( + rootURL: URL, + fileManager: FileManager + ) -> [InstalledBrowserProfile] { + let iniURL = rootURL.appendingPathComponent("profiles.ini", isDirectory: false) + guard let contents = try? String(contentsOf: iniURL, encoding: .utf8) else { + return [] + } + + let sections = parseINISections(contents: contents) + var profiles: [InstalledBrowserProfile] = [] + for section in sections { + guard let pathValue = section["Path"], !pathValue.isEmpty else { continue } + let isRelative = section["IsRelative"] != "0" + let profileURL: URL + if isRelative { + profileURL = rootURL.appendingPathComponent(pathValue, isDirectory: true) + } else { + profileURL = URL(fileURLWithPath: pathValue, isDirectory: true) + } + if looksLikeFirefoxProfile(rootURL: profileURL, fileManager: fileManager) { + let displayName = section["Name"]?.trimmingCharacters(in: .whitespacesAndNewlines) + profiles.append( + InstalledBrowserProfile( + displayName: (displayName?.isEmpty == false ? displayName! : profileURL.lastPathComponent), + rootURL: profileURL, + isDefault: section["Default"] == "1" + ) + ) + } + } + return profiles + } + + private static func parseINISections(contents: String) -> [[String: String]] { + var sections: [[String: String]] = [] + var current: [String: String] = [:] + + func flushCurrent() { + if !current.isEmpty { + sections.append(current) + current.removeAll() + } + } + + for line in contents.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.hasPrefix(";") || trimmed.hasPrefix("#") { + continue + } + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + flushCurrent() + continue + } + guard let separator = trimmed.firstIndex(of: "=") else { continue } + let key = String(trimmed[.. Bool { + let historyURL = rootURL.appendingPathComponent("History", isDirectory: false) + let cookiesURL = rootURL.appendingPathComponent("Cookies", isDirectory: false) + return fileManager.fileExists(atPath: historyURL.path) || fileManager.fileExists(atPath: cookiesURL.path) + } + + private static func looksLikeFirefoxProfile(rootURL: URL, fileManager: FileManager) -> Bool { + let historyURL = rootURL.appendingPathComponent("places.sqlite", isDirectory: false) + let cookiesURL = rootURL.appendingPathComponent("cookies.sqlite", isDirectory: false) + return fileManager.fileExists(atPath: historyURL.path) || fileManager.fileExists(atPath: cookiesURL.path) + } + + private static func webKitProfiles( + descriptor: BrowserImportBrowserDescriptor, + rootURL: URL, + homeDirectoryURL: URL, + fileManager: FileManager + ) -> [InstalledBrowserProfile] { + var profiles: [InstalledBrowserProfile] = [] + if looksLikeWebKitProfile(rootURL: rootURL, fileManager: fileManager) { + profiles.append( + InstalledBrowserProfile( + displayName: String(localized: "browser.profile.default", defaultValue: "Default"), + rootURL: rootURL, + isDefault: true + ) + ) + } + + var profileRoots = [rootURL.appendingPathComponent("Profiles", isDirectory: true)] + if descriptor.id == "safari" { + profileRoots.append( + homeDirectoryURL + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Containers", isDirectory: true) + .appendingPathComponent("com.apple.Safari", isDirectory: true) + .appendingPathComponent("Data", isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Safari", isDirectory: true) + .appendingPathComponent("Profiles", isDirectory: true) + ) + } + + var profileIndex = 1 + for profileRoot in dedupedCanonicalURLs(profileRoots) where fileManager.fileExists(atPath: profileRoot.path) { + let children = (try? fileManager.contentsOfDirectory( + at: profileRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + for child in children { + guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } + guard looksLikeWebKitProfile(rootURL: child, fileManager: fileManager) else { continue } + profiles.append( + InstalledBrowserProfile( + displayName: webKitProfileDisplayName( + directoryName: child.lastPathComponent, + fallbackIndex: profileIndex + ), + rootURL: child, + isDefault: false + ) + ) + profileIndex += 1 + } + } + + return sortProfiles(dedupedProfiles(profiles)) + } + + private static func chromiumProfileNameMap(rootURL: URL) -> [String: String] { + let localStateURL = rootURL.appendingPathComponent("Local State", isDirectory: false) + guard let data = try? Data(contentsOf: localStateURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let profileSection = jsonObject["profile"] as? [String: Any], + let infoCache = profileSection["info_cache"] as? [String: Any] else { + return [:] + } + + var result: [String: String] = [:] + for (directoryName, rawProfileInfo) in infoCache { + guard let profileInfo = rawProfileInfo as? [String: Any], + let name = profileInfo["name"] as? String else { + continue + } + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedName.isEmpty { + result[directoryName] = trimmedName + } + } + return result + } + + private static func chromiumProfileDisplayName( + directoryName: String, + nameMap: [String: String], + isDefault: Bool + ) -> String { + if let mappedName = nameMap[directoryName], !mappedName.isEmpty { + return mappedName + } + if isDefault { + return String(localized: "browser.profile.default", defaultValue: "Default") + } + return directoryName + } + + private static func looksLikeWebKitProfile(rootURL: URL, fileManager: FileManager) -> Bool { + let candidatePaths = [ + "History.db", + "Cookies.binarycookies", + "Cookies.sqlite", + "WebsiteData", + "LocalStorage", + ] + + for candidatePath in candidatePaths { + let url = rootURL.appendingPathComponent(candidatePath, isDirectory: candidatePath != "History.db" && candidatePath != "Cookies.binarycookies" && candidatePath != "Cookies.sqlite") + if fileManager.fileExists(atPath: url.path) { + return true + } + } + return false + } + + private static func webKitProfileDisplayName(directoryName: String, fallbackIndex: Int) -> String { + if directoryName.caseInsensitiveCompare("Default") == .orderedSame { + return String(localized: "browser.profile.default", defaultValue: "Default") + } + if UUID(uuidString: directoryName) != nil { + return String( + format: String( + localized: "browser.import.sourceProfile.fallback", + defaultValue: "Profile %ld" + ), + fallbackIndex + ) + } + return directoryName + } + + private static func defaultApplicationSearchDirectories(homeDirectoryURL: URL) -> [URL] { + [ + URL(fileURLWithPath: "/Applications", isDirectory: true), + homeDirectoryURL.appendingPathComponent("Applications", isDirectory: true), + URL(fileURLWithPath: "/Applications/Setapp", isDirectory: true), + homeDirectoryURL.appendingPathComponent("Applications/Setapp", isDirectory: true), + ] + } + + private static func dedupedProfiles(_ profiles: [InstalledBrowserProfile]) -> [InstalledBrowserProfile] { + var seen = Set() + var result: [InstalledBrowserProfile] = [] + for profile in profiles { + if seen.insert(profile.id).inserted { + result.append(profile) + } + } + return result + } + + private static func sortProfiles(_ profiles: [InstalledBrowserProfile]) -> [InstalledBrowserProfile] { + profiles.sorted { lhs, rhs in + if lhs.isDefault != rhs.isDefault { + return lhs.isDefault && !rhs.isDefault + } + let comparison = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + return lhs.id < rhs.id + } + } +} + +struct BrowserImportOutcomeEntry: Sendable { + let sourceProfileNames: [String] + let destinationProfileName: String + let importedCookies: Int + let skippedCookies: Int + let importedHistoryEntries: Int + let warnings: [String] +} + +struct BrowserImportOutcome: Sendable { + let browserName: String + let scope: BrowserImportScope + let domainFilters: [String] + let createdDestinationProfileNames: [String] + let entries: [BrowserImportOutcomeEntry] + let warnings: [String] + + var totalImportedCookies: Int { + entries.reduce(0) { $0 + $1.importedCookies } + } + + var totalSkippedCookies: Int { + entries.reduce(0) { $0 + $1.skippedCookies } + } + + var totalImportedHistoryEntries: Int { + entries.reduce(0) { $0 + $1.importedHistoryEntries } + } +} + +struct RealizedBrowserImportExecutionEntry: Sendable { + let sourceProfiles: [InstalledBrowserProfile] + let destinationProfileID: UUID + let destinationProfileName: String +} + +struct RealizedBrowserImportExecutionPlan: Sendable { + let mode: BrowserImportDestinationMode + let entries: [RealizedBrowserImportExecutionEntry] + let createdProfiles: [BrowserProfileDefinition] +} + +enum BrowserImportPlanRealizationError: LocalizedError { + case missingDestinationProfile(UUID) + case profileCreationFailed(String) + + var errorDescription: String? { + switch self { + case .missingDestinationProfile: + return String( + localized: "browser.import.error.destinationMissing", + defaultValue: "The selected cmux browser profile no longer exists. Pick a destination profile again." + ) + case .profileCreationFailed(let name): + return String( + format: String( + localized: "browser.import.error.destinationCreateFailed", + defaultValue: "cmux could not create the destination profile \"%@\"." + ), + name + ) + } + } +} + +enum BrowserImportOutcomeFormatter { + static func lines(for outcome: BrowserImportOutcome) -> [String] { + var lines: [String] = [] + lines.append( + String( + format: String( + localized: "browser.import.complete.browser", + defaultValue: "Browser: %@" + ), + outcome.browserName + ) + ) + + if outcome.entries.count == 1, let entry = outcome.entries.first { + if !entry.sourceProfileNames.isEmpty { + lines.append( + String( + format: String( + localized: "browser.import.complete.sourceProfiles", + defaultValue: "Source profiles: %@" + ), + entry.sourceProfileNames.joined(separator: ", ") + ) + ) + } + lines.append( + String( + format: String( + localized: "browser.import.complete.destinationProfile", + defaultValue: "Destination profile: %@" + ), + entry.destinationProfileName + ) + ) + } else if !outcome.entries.isEmpty { + lines.append( + String( + localized: "browser.import.complete.profileMappings", + defaultValue: "Profile mappings:" + ) + ) + for entry in outcome.entries { + let sourceNames = entry.sourceProfileNames.joined(separator: ", ") + lines.append( + String( + format: String( + localized: "browser.import.complete.profileMapping", + defaultValue: "%@ -> %@" + ), + sourceNames, + entry.destinationProfileName + ) + ) + } + } + + lines.append( + String( + format: String( + localized: "browser.import.complete.scope", + defaultValue: "Scope: %@" + ), + outcome.scope.displayName + ) + ) + lines.append( + String( + format: String( + localized: "browser.import.complete.importedCookies", + defaultValue: "Imported cookies: %ld" + ), + outcome.totalImportedCookies + ) + ) + if outcome.totalSkippedCookies > 0 { + lines.append( + String( + format: String( + localized: "browser.import.complete.skippedCookies", + defaultValue: "Skipped cookies: %ld" + ), + outcome.totalSkippedCookies + ) + ) + } + if outcome.scope.includesHistory { + lines.append( + String( + format: String( + localized: "browser.import.complete.importedHistory", + defaultValue: "Imported history entries: %ld" + ), + outcome.totalImportedHistoryEntries + ) + ) + } + if !outcome.domainFilters.isEmpty { + lines.append( + String( + format: String( + localized: "browser.import.complete.domainFilter", + defaultValue: "Domain filter: %@" + ), + outcome.domainFilters.joined(separator: ", ") + ) + ) + } + if !outcome.createdDestinationProfileNames.isEmpty { + lines.append( + String( + format: String( + localized: "browser.import.complete.createdProfiles", + defaultValue: "Created cmux profiles: %@" + ), + outcome.createdDestinationProfileNames.joined(separator: ", ") + ) + ) + } + if !outcome.warnings.isEmpty { + lines.append("") + lines.append( + String( + localized: "browser.import.complete.warnings", + defaultValue: "Warnings:" + ) + ) + for warning in outcome.warnings { + lines.append("- \(warning)") + } + } + + return lines + } +} + +enum BrowserImportDestinationMode: Equatable, Sendable { + case singleDestination + case separateProfiles + case mergeIntoOne +} + +enum BrowserImportDestinationRequest: Equatable, Sendable { + case existing(UUID) + case createNamed(String) +} + +struct BrowserImportExecutionEntry: Equatable, Sendable { + var sourceProfiles: [InstalledBrowserProfile] + var destination: BrowserImportDestinationRequest +} + +struct BrowserImportExecutionPlan: Equatable, Sendable { + var mode: BrowserImportDestinationMode + var entries: [BrowserImportExecutionEntry] +} + +struct BrowserImportStep3Presentation: Equatable { + let showsModeSelector: Bool + let showsSeparateRows: Bool + let showsSingleDestinationPicker: Bool + + init(plan: BrowserImportExecutionPlan) { + showsModeSelector = plan.entries.count > 1 || plan.entries.contains { $0.sourceProfiles.count > 1 } + showsSeparateRows = plan.mode == .separateProfiles + showsSingleDestinationPicker = plan.mode != .separateProfiles + } +} + +enum BrowserImportPlanResolver { + @MainActor + static func defaultPlan( + selectedSourceProfiles: [InstalledBrowserProfile], + destinationProfiles: [BrowserProfileDefinition], + preferredSingleDestinationProfileID: UUID + ) -> BrowserImportExecutionPlan { + let resolvedSourceProfiles = selectedSourceProfiles.isEmpty ? [] : selectedSourceProfiles + + guard resolvedSourceProfiles.count > 1 else { + let destinationRequest: BrowserImportDestinationRequest + if let sourceProfile = resolvedSourceProfiles.first, + let matchingProfile = matchingDestinationProfile( + for: sourceProfile.displayName, + destinationProfiles: destinationProfiles + ) { + destinationRequest = .existing(matchingProfile.id) + } else { + destinationRequest = .existing(preferredSingleDestinationProfileID) + } + + return BrowserImportExecutionPlan( + mode: .singleDestination, + entries: resolvedSourceProfiles.map { + BrowserImportExecutionEntry( + sourceProfiles: [$0], + destination: destinationRequest + ) + } + ) + } + + return separateProfilesPlan( + selectedSourceProfiles: resolvedSourceProfiles, + destinationProfiles: destinationProfiles + ) + } + + static func separateProfilesPlan( + selectedSourceProfiles: [InstalledBrowserProfile], + destinationProfiles: [BrowserProfileDefinition] + ) -> BrowserImportExecutionPlan { + var reservedNames = Set(destinationProfiles.map { normalizedProfileName($0.displayName) }) + + return BrowserImportExecutionPlan( + mode: .separateProfiles, + entries: selectedSourceProfiles.map { profile in + if let matchingProfile = matchingDestinationProfile( + for: profile.displayName, + destinationProfiles: destinationProfiles + ) { + return BrowserImportExecutionEntry( + sourceProfiles: [profile], + destination: .existing(matchingProfile.id) + ) + } + + let createName = nextCreateName( + baseName: profile.displayName, + takenNames: reservedNames + ) + reservedNames.insert(normalizedProfileName(createName)) + return BrowserImportExecutionEntry( + sourceProfiles: [profile], + destination: .createNamed(createName) + ) + } + ) + } + + private static func matchingDestinationProfile( + for sourceProfileName: String, + destinationProfiles: [BrowserProfileDefinition] + ) -> BrowserProfileDefinition? { + let normalizedSourceName = normalizedProfileName(sourceProfileName) + guard !normalizedSourceName.isEmpty else { return nil } + return destinationProfiles.first { + normalizedProfileName($0.displayName) == normalizedSourceName + } + } + + private static func nextCreateName( + baseName: String, + takenNames: Set + ) -> String { + let trimmedBaseName = baseName.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedBaseName = trimmedBaseName.isEmpty ? "Profile" : trimmedBaseName + if !takenNames.contains(normalizedProfileName(resolvedBaseName)) { + return resolvedBaseName + } + + var suffix = 2 + while true { + let candidate = "\(resolvedBaseName) (\(suffix))" + if !takenNames.contains(normalizedProfileName(candidate)) { + return candidate + } + suffix += 1 + } + } + + private static func normalizedProfileName(_ rawName: String) -> String { + rawName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + @MainActor + static func realize( + plan: BrowserImportExecutionPlan, + profileStore: BrowserProfileStore = .shared + ) throws -> RealizedBrowserImportExecutionPlan { + var realizedEntries: [RealizedBrowserImportExecutionEntry] = [] + var createdProfiles: [BrowserProfileDefinition] = [] + + for entry in plan.entries { + let destinationProfile: BrowserProfileDefinition + switch entry.destination { + case .existing(let id): + guard let existingProfile = profileStore.profileDefinition(id: id) else { + throw BrowserImportPlanRealizationError.missingDestinationProfile(id) + } + destinationProfile = existingProfile + case .createNamed(let name): + if let existingProfile = matchingDestinationProfile( + for: name, + destinationProfiles: profileStore.profiles + ) { + destinationProfile = existingProfile + } else if let createdProfile = profileStore.createProfile(named: name) { + createdProfiles.append(createdProfile) + destinationProfile = createdProfile + } else { + throw BrowserImportPlanRealizationError.profileCreationFailed(name) + } + } + + realizedEntries.append( + RealizedBrowserImportExecutionEntry( + sourceProfiles: entry.sourceProfiles, + destinationProfileID: destinationProfile.id, + destinationProfileName: destinationProfile.displayName + ) + ) + } + + return RealizedBrowserImportExecutionPlan( + mode: plan.mode, + entries: realizedEntries, + createdProfiles: createdProfiles + ) + } +} + +#if canImport(CommonCrypto) && canImport(Security) +private struct ChromiumCookieKeychainItem: Hashable { + let service: String + let account: String +} + +private final class ChromiumCookieDecryptor { + private enum KeychainLookupResult { + case success(Data) + case failure(OSStatus) + } + + enum FailureReason { + case keychain(OSStatus) + case itemNotFound + case unreadableSecret + case decrypt + case unsupportedFormat + } + + private let browser: InstalledBrowserCandidate + private var cachedKeychainItem: ChromiumCookieKeychainItem? + private var cachedPasswordData: Data? + private var attemptedLookup = false + private(set) var lastFailureReason: FailureReason? + + init(browser: InstalledBrowserCandidate) { + self.browser = browser + } + + var resolvedKeychainItemName: String? { + cachedKeychainItem?.service + } + + func decryptCookieValue(encryptedValue: Data, host: String) -> String? { + guard let versionPrefix = chromiumVersionPrefix(in: encryptedValue) else { + lastFailureReason = .unsupportedFormat + return nil + } + + guard let passwordData = passwordData() else { + return nil + } + + let ciphertext = encryptedValue.dropFirst(versionPrefix.count) + guard let key = deriveKey(from: passwordData), + let plaintext = decrypt(ciphertext: Data(ciphertext), key: key), + let cookieValue = decodePlaintext(plaintext, host: host) else { + lastFailureReason = .decrypt + return nil + } + + lastFailureReason = nil + return cookieValue + } + + func warningMessage(browserName: String, skippedCount: Int) -> String? { + guard skippedCount > 0, let failure = lastFailureReason else { return nil } + switch failure { + case .keychain, .itemNotFound, .unreadableSecret: + let itemName = resolvedKeychainItemName ?? suggestedKeychainItems().first?.service ?? "\(browserName) Storage Key" + return String( + format: String( + localized: "browser.import.warning.keychainDecryptFailed", + defaultValue: "Skipped %ld encrypted %@ cookies because %@ could not be unlocked from Keychain." + ), + skippedCount, + browserName, + itemName + ) + case .decrypt, .unsupportedFormat: + return String( + format: String( + localized: "browser.import.warning.encryptedCookiesSkipped", + defaultValue: "Skipped %ld encrypted cookies that require Keychain decryption." + ), + skippedCount + ) + } + } + + private func passwordData() -> Data? { + if let cachedPasswordData { + return cachedPasswordData + } + guard !attemptedLookup else { + return nil + } + attemptedLookup = true + + for item in suggestedKeychainItems() { + switch readPasswordData(item: item) { + case .success(let passwordData): + guard !passwordData.isEmpty else { + cachedKeychainItem = item + lastFailureReason = .unreadableSecret + return nil + } + cachedKeychainItem = item + cachedPasswordData = passwordData + lastFailureReason = nil + return passwordData + case .failure(let status): + if status == errSecItemNotFound { + continue + } + cachedKeychainItem = item + lastFailureReason = .keychain(status) + return nil + } + } + + lastFailureReason = .itemNotFound + return nil + } + + private func suggestedKeychainItems() -> [ChromiumCookieKeychainItem] { + var result: [ChromiumCookieKeychainItem] = [] + var seen = Set() + + func append(service: String, account: String) { + let trimmedService = service.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAccount = account.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedService.isEmpty, !trimmedAccount.isEmpty else { return } + let item = ChromiumCookieKeychainItem(service: trimmedService, account: trimmedAccount) + if seen.insert(item).inserted { + result.append(item) + } + } + + for baseName in keychainBaseNames() { + append(service: "\(baseName) Storage Key", account: baseName) + append(service: "\(baseName) Safe Storage", account: baseName) + } + + for baseName in keychainBaseNames() { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: baseName, + kSecReturnAttributes: true, + kSecMatchLimit: kSecMatchLimitAll, + ] + var rawResult: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &rawResult) + guard status == errSecSuccess else { continue } + let attributesList = rawResult as? [[String: Any]] ?? [] + for attributes in attributesList { + guard let service = attributes[kSecAttrService as String] as? String else { continue } + guard service.contains("Storage Key") || service.contains("Safe Storage") else { continue } + append(service: service, account: baseName) + } + } + + return result + } + + private func keychainBaseNames() -> [String] { + var result: [String] = [] + var seen = Set() + + func append(_ rawName: String?) { + guard let rawName else { return } + let trimmedName = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + if seen.insert(trimmedName).inserted { + result.append(trimmedName) + } + } + + append(browser.displayName) + append(browser.appURL?.deletingPathExtension().lastPathComponent) + append(browser.descriptor.appNames.first?.replacingOccurrences(of: ".app", with: "")) + + if let appURL = browser.appURL, + let bundle = Bundle(url: appURL) { + append(bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) + append(bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + } + + for name in Array(result) { + if name.hasPrefix("Google ") { + append(String(name.dropFirst("Google ".count))) + } + if name.hasSuffix(" Browser") { + append(String(name.dropLast(" Browser".count))) + } + } + + switch browser.descriptor.id { + case "google-chrome": + append("Chrome") + case "chromium": + append("Chromium") + case "brave": + append("Brave") + case "helium": + append("Helium") + default: + break + } + + return result + } + + private func readPasswordData(item: ChromiumCookieKeychainItem) -> KeychainLookupResult { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: item.service, + kSecAttrAccount: item.account, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + + var rawResult: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &rawResult) + guard status == errSecSuccess else { + return .failure(status) + } + guard let passwordData = rawResult as? Data else { + return .failure(errSecDecode) + } + return .success(passwordData) + } + + private func chromiumVersionPrefix(in encryptedValue: Data) -> Data? { + for prefix in [Data("v10".utf8), Data("v11".utf8)] where encryptedValue.starts(with: prefix) { + return prefix + } + return nil + } + + private func deriveKey(from passwordData: Data) -> Data? { + let salt = Data("saltysalt".utf8) + var derivedKey = Data(count: kCCKeySizeAES128) + + let status = derivedKey.withUnsafeMutableBytes { derivedBytes in + passwordData.withUnsafeBytes { passwordBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordBytes.baseAddress?.assumingMemoryBound(to: Int8.self), + passwordData.count, + saltBytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + 1003, + derivedBytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + kCCKeySizeAES128 + ) + } + } + } + + guard status == kCCSuccess else { return nil } + return derivedKey + } + + private func decrypt(ciphertext: Data, key: Data) -> Data? { + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + var plaintext = Data(count: ciphertext.count + kCCBlockSizeAES128) + var plaintextLength = 0 + let plaintextCapacity = plaintext.count + + let status = plaintext.withUnsafeMutableBytes { plaintextBytes in + ciphertext.withUnsafeBytes { ciphertextBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + ciphertextBytes.baseAddress, + ciphertext.count, + plaintextBytes.baseAddress, + plaintextCapacity, + &plaintextLength + ) + } + } + } + } + + guard status == kCCSuccess else { return nil } + plaintext.removeSubrange(plaintextLength...) + return plaintext + } + + private func decodePlaintext(_ plaintext: Data, host: String) -> String? { + if let value = String(data: plaintext, encoding: .utf8) { + return value + } + + let hostDigest = Data(SHA256.hash(data: Data(host.utf8))) + if plaintext.starts(with: hostDigest) { + return String(data: plaintext.dropFirst(hostDigest.count), encoding: .utf8) + } + + return nil + } +} +#else +private final class ChromiumCookieDecryptor { + init(browser: InstalledBrowserCandidate) {} + + func decryptCookieValue(encryptedValue: Data, host: String) -> String? { nil } + + func warningMessage(browserName: String, skippedCount: Int) -> String? { + guard skippedCount > 0 else { return nil } + return String( + format: String( + localized: "browser.import.warning.encryptedCookiesSkipped", + defaultValue: "Skipped %ld encrypted cookies that require Keychain decryption." + ), + skippedCount + ) + } +} +#endif + +enum BrowserDataImporter { + private struct CookieImportResult { + var importedCount: Int = 0 + var skippedCount: Int = 0 + var warnings: [String] = [] + } + + private struct HistoryImportResult { + var importedCount: Int = 0 + var warnings: [String] = [] + } + + private struct HistoryRow { + let url: String + let title: String? + let visitCount: Int + let lastVisited: Date + } + + static func parseDomainFilters(_ raw: String) -> [String] { + var result: [String] = [] + var seen = Set() + let separators = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",;")) + for token in raw.components(separatedBy: separators) { + var value = token.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if value.hasPrefix("*.") { + value.removeFirst(2) + } + while value.hasPrefix(".") { + value.removeFirst() + } + guard !value.isEmpty else { continue } + guard seen.insert(value).inserted else { continue } + result.append(value) + } + return result + } + + static func importData( + from browser: InstalledBrowserCandidate, + plan: RealizedBrowserImportExecutionPlan, + scope: BrowserImportScope, + domainFilters: [String] + ) async -> BrowserImportOutcome { + var outcomeEntries: [BrowserImportOutcomeEntry] = [] + var warnings: [String] = [] + var seenWarnings = Set() + + for entry in plan.entries { + let outcomeEntry = await importEntry( + from: browser, + sourceProfiles: entry.sourceProfiles, + destinationProfileID: entry.destinationProfileID, + destinationProfileName: entry.destinationProfileName, + scope: scope, + domainFilters: domainFilters + ) + outcomeEntries.append(outcomeEntry) + for warning in outcomeEntry.warnings where seenWarnings.insert(warning).inserted { + warnings.append(warning) + } + } + + if scope == .everything { + let unavailableWarning = String( + localized: "browser.import.warning.additionalDataUnavailable", + defaultValue: "Bookmarks, settings, and extensions import are not available yet. Imported cookies and history only." + ) + if seenWarnings.insert(unavailableWarning).inserted { + warnings.append(unavailableWarning) + } + } + + return BrowserImportOutcome( + browserName: browser.displayName, + scope: scope, + domainFilters: domainFilters, + createdDestinationProfileNames: plan.createdProfiles.map(\.displayName), + entries: outcomeEntries, + warnings: warnings + ) + } + + private static func importEntry( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + destinationProfileName: String, + scope: BrowserImportScope, + domainFilters: [String] + ) async -> BrowserImportOutcomeEntry { + let resolvedSourceProfiles = sourceProfiles.isEmpty ? browser.profiles : sourceProfiles + var cookieResult = CookieImportResult() + if scope.includesCookies { + cookieResult = await importCookies( + from: browser, + sourceProfiles: resolvedSourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + } + + var historyResult = HistoryImportResult() + if scope.includesHistory { + historyResult = await importHistory( + from: browser, + sourceProfiles: resolvedSourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + } + + var warnings = cookieResult.warnings + warnings.append(contentsOf: historyResult.warnings) + return BrowserImportOutcomeEntry( + sourceProfileNames: resolvedSourceProfiles.map(\.displayName), + destinationProfileName: destinationProfileName, + importedCookies: cookieResult.importedCount, + skippedCookies: cookieResult.skippedCount, + importedHistoryEntries: historyResult.importedCount, + warnings: warnings + ) + } + + private static func importCookies( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> CookieImportResult { + switch browser.family { + case .firefox: + return await importFirefoxCookies( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + case .chromium: + return await importChromiumCookies( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + case .webkit: + if browser.descriptor.id == "safari" { + return CookieImportResult( + importedCount: 0, + skippedCount: 0, + warnings: [ + String( + localized: "browser.import.warning.safariCookiesUnsupported", + defaultValue: "Safari cookies are stored in Cookies.binarycookies and are not yet supported by this importer." + ) + ] + ) + } + return CookieImportResult( + importedCount: 0, + skippedCount: 0, + warnings: [ + String( + format: String( + localized: "browser.import.warning.cookieImportUnsupported", + defaultValue: "%@ cookie import is not implemented yet." + ), + browser.displayName + ) + ] + ) + } + } + + private static func importHistory( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> HistoryImportResult { + switch browser.family { + case .firefox: + return await importFirefoxHistory( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + case .chromium: + return await importChromiumHistory( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + case .webkit: + return await importWebKitHistory( + from: browser, + sourceProfiles: sourceProfiles, + destinationProfileID: destinationProfileID, + domainFilters: domainFilters + ) + } + } + + private static func importFirefoxCookies( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> CookieImportResult { + let fileManager = FileManager.default + var cookies: [HTTPCookie] = [] + var warnings: [String] = [] + + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("cookies.sqlite", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: "SELECT host, name, value, path, expiry, isSecure FROM moz_cookies" + ) { statement in + let host = sqliteColumnText(statement, index: 0) ?? "" + let name = sqliteColumnText(statement, index: 1) ?? "" + let value = sqliteColumnText(statement, index: 2) ?? "" + let path = sqliteColumnText(statement, index: 3) ?? "/" + let expiry = sqliteColumnInt64(statement, index: 4) + let isSecure = sqliteColumnInt64(statement, index: 5) != 0 + + guard !name.isEmpty else { return } + guard domainMatches(host: host, filters: domainFilters) else { return } + + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: host, + .path: path.isEmpty ? "/" : path, + .name: name, + .value: value, + ] + if isSecure { + properties[.secure] = "TRUE" + } + if expiry > 0 { + properties[.expires] = Date(timeIntervalSince1970: TimeInterval(expiry)) + } + if let cookie = HTTPCookie(properties: properties) { + cookies.append(cookie) + } + } + } catch { + warnings.append( + String( + format: String( + localized: "browser.import.warning.firefoxCookiesReadFailed", + defaultValue: "Failed reading Firefox cookies at %@: %@" + ), + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) + } + } + + let dedupedCookies = dedupeCookies(cookies) + let importedCount = await setCookiesInStore(dedupedCookies, destinationProfileID: destinationProfileID) + return CookieImportResult(importedCount: importedCount, skippedCount: max(0, dedupedCookies.count - importedCount), warnings: warnings) + } + + private static func importChromiumCookies( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> CookieImportResult { + let fileManager = FileManager.default + var cookies: [HTTPCookie] = [] + var warnings: [String] = [] + var skippedEncryptedCookies = 0 + let decryptor = ChromiumCookieDecryptor(browser: browser) + + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("Cookies", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: "SELECT host_key, name, value, path, expires_utc, is_secure, encrypted_value FROM cookies" + ) { statement in + let host = sqliteColumnText(statement, index: 0) ?? "" + let name = sqliteColumnText(statement, index: 1) ?? "" + let value = sqliteColumnText(statement, index: 2) ?? "" + let path = sqliteColumnText(statement, index: 3) ?? "/" + let expiresUTC = sqliteColumnInt64(statement, index: 4) + let isSecure = sqliteColumnInt64(statement, index: 5) != 0 + let encryptedValue = sqliteColumnData(statement, index: 6) + + guard !name.isEmpty else { return } + guard domainMatches(host: host, filters: domainFilters) else { return } + + var usableValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + if usableValue.isEmpty && !encryptedValue.isEmpty { + if let decryptedValue = decryptor.decryptCookieValue( + encryptedValue: encryptedValue, + host: host + ) { + usableValue = decryptedValue + } else { + skippedEncryptedCookies += 1 + return + } + } + + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: host, + .path: path.isEmpty ? "/" : path, + .name: name, + .value: usableValue, + ] + if isSecure { + properties[.secure] = "TRUE" + } + if let expiresDate = chromiumDate(fromWebKitMicroseconds: expiresUTC) { + properties[.expires] = expiresDate + } + if let cookie = HTTPCookie(properties: properties) { + cookies.append(cookie) + } + } + } catch { + warnings.append( + String( + format: String( + localized: "browser.import.warning.browserCookiesReadFailed", + defaultValue: "Failed reading %@ cookies at %@: %@" + ), + browser.displayName, + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) + } + } + + let dedupedCookies = dedupeCookies(cookies) + let importedCount = await setCookiesInStore(dedupedCookies, destinationProfileID: destinationProfileID) + if let warning = decryptor.warningMessage( + browserName: browser.displayName, + skippedCount: skippedEncryptedCookies + ) { + warnings.append(warning) + } + let skippedCount = max(0, dedupedCookies.count - importedCount) + skippedEncryptedCookies + return CookieImportResult(importedCount: importedCount, skippedCount: skippedCount, warnings: warnings) + } + + private static func importFirefoxHistory( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> HistoryImportResult { + let fileManager = FileManager.default + var rows: [HistoryRow] = [] + var warnings: [String] = [] + + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("places.sqlite", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: """ + SELECT url, title, visit_count, last_visit_date + FROM moz_places + WHERE url LIKE 'http%' + ORDER BY last_visit_date DESC + LIMIT 5000 + """ + ) { statement in + let url = sqliteColumnText(statement, index: 0) ?? "" + let title = sqliteColumnText(statement, index: 1) + let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) + let lastVisitMicros = sqliteColumnInt64(statement, index: 3) + guard let parsedURL = URL(string: url), + let host = parsedURL.host, + domainMatches(host: host, filters: domainFilters) else { + return + } + let lastVisited = firefoxDate(fromUnixMicroseconds: lastVisitMicros) ?? .distantPast + rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) + } + } catch { + warnings.append( + String( + format: String( + localized: "browser.import.warning.firefoxHistoryReadFailed", + defaultValue: "Failed reading Firefox history at %@: %@" + ), + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) + } + } + + let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) + return HistoryImportResult(importedCount: importedCount, warnings: warnings) + } + + private static func importChromiumHistory( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> HistoryImportResult { + let fileManager = FileManager.default + var rows: [HistoryRow] = [] + var warnings: [String] = [] + + let databaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("History", isDirectory: false) + }.filter { fileManager.fileExists(atPath: $0.path) } + + for databaseURL in databaseURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: """ + SELECT url, title, visit_count, last_visit_time + FROM urls + WHERE url LIKE 'http%' + ORDER BY last_visit_time DESC + LIMIT 5000 + """ + ) { statement in + let url = sqliteColumnText(statement, index: 0) ?? "" + let title = sqliteColumnText(statement, index: 1) + let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) + let lastVisitMicros = sqliteColumnInt64(statement, index: 3) + guard let parsedURL = URL(string: url), + let host = parsedURL.host, + domainMatches(host: host, filters: domainFilters) else { + return + } + let lastVisited = chromiumDate(fromWebKitMicroseconds: lastVisitMicros) ?? .distantPast + rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) + } + } catch { + warnings.append( + String( + format: String( + localized: "browser.import.warning.browserHistoryReadFailed", + defaultValue: "Failed reading %@ history at %@: %@" + ), + browser.displayName, + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) + } + } + + let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) + return HistoryImportResult(importedCount: importedCount, warnings: warnings) + } + + private static func importWebKitHistory( + from browser: InstalledBrowserCandidate, + sourceProfiles: [InstalledBrowserProfile], + destinationProfileID: UUID, + domainFilters: [String] + ) async -> HistoryImportResult { + let fileManager = FileManager.default + var rows: [HistoryRow] = [] + var warnings: [String] = [] + + var candidateDatabaseURLs = sourceProfiles.map { + $0.rootURL.appendingPathComponent("History.db", isDirectory: false) + } + if browser.descriptor.id == "safari" { + candidateDatabaseURLs.append( + browser.homeDirectoryURL + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Safari", isDirectory: true) + .appendingPathComponent("History.db", isDirectory: false) + ) + } + let uniqueURLs = dedupedCanonicalURLs(candidateDatabaseURLs).filter { fileManager.fileExists(atPath: $0.path) } + + if uniqueURLs.isEmpty { + return HistoryImportResult( + importedCount: 0, + warnings: [ + String( + format: String( + localized: "browser.import.warning.noHistoryDatabase", + defaultValue: "No history database found for %@." + ), + browser.displayName + ) + ] + ) + } + + for databaseURL in uniqueURLs { + do { + try querySQLiteRows( + sourceDatabaseURL: databaseURL, + sql: """ + SELECT history_items.url, + history_items.title, + COUNT(history_visits.id) AS visit_count, + MAX(history_visits.visit_time) AS last_visit_time + FROM history_items + JOIN history_visits + ON history_items.id = history_visits.history_item + GROUP BY history_items.url + ORDER BY last_visit_time DESC + LIMIT 5000 + """ + ) { statement in + let url = sqliteColumnText(statement, index: 0) ?? "" + let title = sqliteColumnText(statement, index: 1) + let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) + let lastVisitReferenceSeconds = sqliteColumnDouble(statement, index: 3) + guard let parsedURL = URL(string: url), + let host = parsedURL.host, + domainMatches(host: host, filters: domainFilters) else { + return + } + let lastVisited = Date(timeIntervalSinceReferenceDate: lastVisitReferenceSeconds) + rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) + } + } catch { + warnings.append( + String( + format: String( + localized: "browser.import.warning.browserHistoryReadFailed", + defaultValue: "Failed reading %@ history at %@: %@" + ), + browser.displayName, + databaseURL.lastPathComponent, + error.localizedDescription + ) + ) + } + } + + let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) + return HistoryImportResult(importedCount: importedCount, warnings: warnings) + } + + private static func mergeHistoryRows(_ rows: [HistoryRow], destinationProfileID: UUID) async -> Int { + guard !rows.isEmpty else { return 0 } + return await MainActor.run { + let entries = rows.compactMap { row -> BrowserHistoryStore.Entry? in + guard let parsedURL = URL(string: row.url), + let scheme = parsedURL.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + let trimmedTitle = row.title?.trimmingCharacters(in: .whitespacesAndNewlines) + return BrowserHistoryStore.Entry( + id: UUID(), + url: parsedURL.absoluteString, + title: trimmedTitle, + lastVisited: row.lastVisited, + visitCount: max(1, row.visitCount) + ) + } + let historyStore = BrowserProfileStore.shared.historyStore(for: destinationProfileID) + return historyStore.mergeImportedEntries(entries) + } + } + + private static func setCookiesInStore(_ cookies: [HTTPCookie], destinationProfileID: UUID) async -> Int { + guard !cookies.isEmpty else { return 0 } + let store = await MainActor.run { + BrowserProfileStore.shared.websiteDataStore(for: destinationProfileID).httpCookieStore + } + var importedCount = 0 + for cookie in cookies { + await setCookie(cookie, in: store) + importedCount += 1 + } + return importedCount + } + + @MainActor + private static func setCookie(_ cookie: HTTPCookie, in store: WKHTTPCookieStore) async { + await withCheckedContinuation { continuation in + store.setCookie(cookie) { + continuation.resume() + } + } + } + + private static func dedupeCookies(_ cookies: [HTTPCookie]) -> [HTTPCookie] { + var dedupedByKey: [String: HTTPCookie] = [:] + for cookie in cookies { + let key = "\(cookie.name.lowercased())|\(cookie.domain.lowercased())|\(cookie.path)" + if let existing = dedupedByKey[key] { + let existingExpiry = existing.expiresDate ?? .distantPast + let candidateExpiry = cookie.expiresDate ?? .distantPast + if candidateExpiry >= existingExpiry { + dedupedByKey[key] = cookie + } + } else { + dedupedByKey[key] = cookie + } + } + return Array(dedupedByKey.values) + } + + private static func domainMatches(host: String, filters: [String]) -> Bool { + if filters.isEmpty { return true } + var normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + while normalizedHost.hasPrefix(".") { + normalizedHost.removeFirst() + } + guard !normalizedHost.isEmpty else { return false } + for filter in filters { + if normalizedHost == filter { return true } + if normalizedHost.hasSuffix(".\(filter)") { return true } + } + return false + } + + private static func chromiumDate(fromWebKitMicroseconds rawValue: Int64) -> Date? { + guard rawValue > 0 else { return nil } + let unixSeconds = (Double(rawValue) / 1_000_000.0) - 11_644_473_600.0 + guard unixSeconds.isFinite else { return nil } + return Date(timeIntervalSince1970: unixSeconds) + } + + private static func firefoxDate(fromUnixMicroseconds rawValue: Int64) -> Date? { + guard rawValue > 0 else { return nil } + let seconds = Double(rawValue) / 1_000_000.0 + guard seconds.isFinite else { return nil } + return Date(timeIntervalSince1970: seconds) + } + + private static func querySQLiteRows( + sourceDatabaseURL: URL, + sql: String, + rowHandler: (OpaquePointer) throws -> Void + ) throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-browser-import-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: tempRoot, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempRoot) } + + let snapshotURL = tempRoot.appendingPathComponent(sourceDatabaseURL.lastPathComponent, isDirectory: false) + try fileManager.copyItem(at: sourceDatabaseURL, to: snapshotURL) + + let walSourceURL = URL(fileURLWithPath: "\(sourceDatabaseURL.path)-wal") + let walSnapshotURL = URL(fileURLWithPath: "\(snapshotURL.path)-wal") + if fileManager.fileExists(atPath: walSourceURL.path) { + try? fileManager.copyItem(at: walSourceURL, to: walSnapshotURL) + } + let shmSourceURL = URL(fileURLWithPath: "\(sourceDatabaseURL.path)-shm") + let shmSnapshotURL = URL(fileURLWithPath: "\(snapshotURL.path)-shm") + if fileManager.fileExists(atPath: shmSourceURL.path) { + try? fileManager.copyItem(at: shmSourceURL, to: shmSnapshotURL) + } + + var database: OpaquePointer? + let openCode = sqlite3_open_v2(snapshotURL.path, &database, SQLITE_OPEN_READONLY, nil) + guard openCode == SQLITE_OK, let database else { + let message = sqliteMessage(from: database) ?? "unknown SQLite open failure" + sqlite3_close(database) + throw NSError(domain: "BrowserDataImporter", code: Int(openCode), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + defer { sqlite3_close(database) } + + var statement: OpaquePointer? + let prepareCode = sqlite3_prepare_v2(database, sql, -1, &statement, nil) + guard prepareCode == SQLITE_OK, let statement else { + let message = sqliteMessage(from: database) ?? "unknown SQLite prepare failure" + sqlite3_finalize(statement) + throw NSError(domain: "BrowserDataImporter", code: Int(prepareCode), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + defer { sqlite3_finalize(statement) } + + while true { + let stepCode = sqlite3_step(statement) + if stepCode == SQLITE_ROW { + try rowHandler(statement) + continue + } + if stepCode == SQLITE_DONE { + break + } + let message = sqliteMessage(from: database) ?? "unknown SQLite step failure" + throw NSError(domain: "BrowserDataImporter", code: Int(stepCode), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + } + + private static func sqliteMessage(from database: OpaquePointer?) -> String? { + guard let database, let cString = sqlite3_errmsg(database) else { return nil } + return String(cString: cString) + } + + private static func sqliteColumnText(_ statement: OpaquePointer, index: Int32) -> String? { + guard let cValue = sqlite3_column_text(statement, index) else { return nil } + return String(cString: cValue) + } + + private static func sqliteColumnInt64(_ statement: OpaquePointer, index: Int32) -> Int64 { + sqlite3_column_int64(statement, index) + } + + private static func sqliteColumnDouble(_ statement: OpaquePointer, index: Int32) -> Double { + sqlite3_column_double(statement, index) + } + + private static func sqliteColumnBytes(_ statement: OpaquePointer, index: Int32) -> Int { + Int(sqlite3_column_bytes(statement, index)) + } + + private static func sqliteColumnData(_ statement: OpaquePointer, index: Int32) -> Data { + let length = Int(sqlite3_column_bytes(statement, index)) + guard length > 0, let pointer = sqlite3_column_blob(statement, index) else { + return Data() + } + return Data(bytes: pointer, count: length) + } +} + +#if DEBUG +enum BrowserImportUITestFixtureLoader { + private struct BrowserFixture: Decodable { + let browserName: String + let profiles: [String] + } + + static func browsers(from environment: [String: String]) -> [InstalledBrowserCandidate]? { + guard let rawFixture = environment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"], + let data = rawFixture.data(using: .utf8), + let fixture = try? JSONDecoder().decode(BrowserFixture.self, from: data) else { + return nil + } + + let resolvedProfiles = fixture.profiles.enumerated().map { index, name in + InstalledBrowserProfile( + displayName: name, + rootURL: FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-browser-import") + .appendingPathComponent( + fixture.browserName + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + ) + .appendingPathComponent("\(index)-\(name)") + .standardizedFileURL, + isDefault: index == 0 + ) + } + + let descriptor = InstalledBrowserDetector.allBrowserDescriptors.first(where: { + $0.displayName == fixture.browserName + }) ?? BrowserImportBrowserDescriptor( + id: fixture.browserName + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")), + displayName: fixture.browserName, + family: .chromium, + tier: 0, + bundleIdentifiers: [], + appNames: [], + dataRootRelativePaths: [], + dataArtifactRelativePaths: [], + supportsDataOnlyDetection: false + ) + + return [ + InstalledBrowserCandidate( + descriptor: descriptor, + resolvedFamily: descriptor.family, + homeDirectoryURL: FileManager.default.homeDirectoryForCurrentUser, + appURL: nil, + dataRootURL: nil, + profiles: resolvedProfiles, + detectionSignals: ["ui-test-fixture"], + detectionScore: Int.max + ) + ] + } + + static func destinationProfiles(from environment: [String: String]) -> [BrowserProfileDefinition]? { + guard let rawDestinations = environment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"], + let data = rawDestinations.data(using: .utf8), + let names = try? JSONDecoder().decode([String].self, from: data), + !names.isEmpty else { + return nil + } + + return names.enumerated().map { index, rawName in + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + if name.localizedCaseInsensitiveCompare("Default") == .orderedSame { + return BrowserProfileDefinition( + id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!, + displayName: "Default", + createdAt: .distantPast, + isBuiltInDefault: true + ) + } + return BrowserProfileDefinition( + id: UUID(), + displayName: name.isEmpty ? "Profile \(index + 1)" : name, + createdAt: .distantPast, + isBuiltInDefault: false + ) + } + } +} +#endif + +@MainActor +final class BrowserDataImportCoordinator { + static let shared = BrowserDataImportCoordinator() + + private var importInProgress = false + + private init() {} + + func presentImportDialog(defaultDestinationProfileID: UUID? = nil) { + presentImportDialog(prefilledBrowsers: nil, defaultDestinationProfileID: defaultDestinationProfileID) + } + + private struct ImportSelection { + let browser: InstalledBrowserCandidate + let executionPlan: BrowserImportExecutionPlan + let scope: BrowserImportScope + let domainFilters: [String] + } + + private func presentImportDialog( + prefilledBrowsers: [InstalledBrowserCandidate]?, + defaultDestinationProfileID: UUID? + ) { + guard !importInProgress else { return } +#if DEBUG + let environment = ProcessInfo.processInfo.environment + let fixtureBrowsers = BrowserImportUITestFixtureLoader.browsers(from: environment) + let fixtureDestinationProfiles = BrowserImportUITestFixtureLoader.destinationProfiles(from: environment) + let browsers = prefilledBrowsers ?? fixtureBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() +#else + let fixtureDestinationProfiles: [BrowserProfileDefinition]? = nil + let browsers = prefilledBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() +#endif + guard !browsers.isEmpty else { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + localized: "browser.import.noBrowsers.title", + defaultValue: "No importable browsers found" + ) + alert.informativeText = String( + localized: "browser.import.noBrowsers.message", + defaultValue: "cmux could not find browser profiles to import from on this Mac." + ) + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + return + } + + guard let selection = promptForSelection( + browsers: browsers, + destinationProfiles: fixtureDestinationProfiles, + defaultDestinationProfileID: defaultDestinationProfileID + ) else { return } + +#if DEBUG + if captureSelectionIfRequested(selection, destinationProfiles: fixtureDestinationProfiles) { + return + } +#endif + let realizedPlan: RealizedBrowserImportExecutionPlan + do { + realizedPlan = try BrowserImportPlanResolver.realize(plan: selection.executionPlan) + } catch { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + localized: "browser.import.error.title", + defaultValue: "Import could not start" + ) + alert.informativeText = error.localizedDescription + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + return + } + importInProgress = true + + let progressWindow = showProgressWindow( + title: String( + localized: "browser.import.progress.title", + defaultValue: "Importing Browser Data" + ), + message: String( + format: String( + localized: "browser.import.progress.message", + defaultValue: "Importing %@ from %@…" + ), + selection.scope.displayName.lowercased(), + selection.browser.displayName + ) + ) + + Task.detached(priority: .userInitiated) { + let outcome = await BrowserDataImporter.importData( + from: selection.browser, + plan: realizedPlan, + scope: selection.scope, + domainFilters: selection.domainFilters + ) + + await MainActor.run { + self.hideProgressWindow(progressWindow) + self.presentOutcome(outcome) + self.importInProgress = false + } + } + } + + private func promptForSelection( + browsers: [InstalledBrowserCandidate], + destinationProfiles: [BrowserProfileDefinition]?, + defaultDestinationProfileID: UUID? + ) -> ImportSelection? { + guard !browsers.isEmpty else { return nil } + let wizard = ImportWizardWindowController( + browsers: browsers, + destinationProfiles: destinationProfiles, + defaultDestinationProfileID: defaultDestinationProfileID + ) + return wizard.runModal() + } + +#if DEBUG + private struct CapturedImportSelection: Encodable { + struct Entry: Encodable { + let sourceProfiles: [String] + let destinationKind: String + let destinationName: String + } + + let browserName: String + let mode: String + let scope: String + let domainFilters: [String] + let entries: [Entry] + } + + private func captureSelectionIfRequested( + _ selection: ImportSelection, + destinationProfiles: [BrowserProfileDefinition]? + ) -> Bool { + let environment = ProcessInfo.processInfo.environment + guard environment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] == "capture-only" else { return false } + guard let path = environment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"], !path.isEmpty else { + return true + } + + let availableDestinationProfiles = destinationProfiles ?? BrowserProfileStore.shared.profiles + let payload = CapturedImportSelection( + browserName: selection.browser.displayName, + mode: captureModeName(selection.executionPlan.mode), + scope: selection.scope.rawValue, + domainFilters: selection.domainFilters, + entries: selection.executionPlan.entries.map { entry in + let destinationKind: String + let destinationName: String + switch entry.destination { + case .existing(let id): + destinationKind = "existing" + destinationName = availableDestinationProfiles.first(where: { $0.id == id })?.displayName + ?? BrowserProfileStore.shared.displayName(for: id) + case .createNamed(let name): + destinationKind = "create" + destinationName = name + } + return CapturedImportSelection.Entry( + sourceProfiles: entry.sourceProfiles.map(\.displayName), + destinationKind: destinationKind, + destinationName: destinationName + ) + } + ) + + guard let data = try? JSONEncoder().encode(payload) else { return true } + let url = URL(fileURLWithPath: path) + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + try? data.write(to: url) + return true + } + + private func captureModeName(_ mode: BrowserImportDestinationMode) -> String { + switch mode { + case .singleDestination: + return "singleDestination" + case .separateProfiles: + return "separateProfiles" + case .mergeIntoOne: + return "mergeIntoOne" + } + } +#endif + + @MainActor + private final class ImportWizardWindowController: NSObject, @preconcurrency NSWindowDelegate { + private final class FlippedDocumentView: NSView { + override var isFlipped: Bool { true } + } + + private enum Step { + case source + case sourceProfiles + case dataTypes + } + + private let browsers: [InstalledBrowserCandidate] + private let destinationProfiles: [BrowserProfileDefinition] + private let initialDestinationProfileID: UUID + + private var step: Step = .source + private var didFinishModal = false + private(set) var selection: ImportSelection? + private var selectedSourceProfileIDsByBrowserID: [String: Set] = [:] + private var sourceProfileCheckboxes: [NSButton] = [] + private var destinationMode: BrowserImportDestinationMode = .singleDestination + private var separateExecutionEntries: [BrowserImportExecutionEntry] = [] + private var separateDestinationOptionsByEntryIndex: [Int: [BrowserImportDestinationRequest]] = [:] + private var mergeDestinationProfileID: UUID + + private let panel: NSPanel + + private let stepLabel = NSTextField(labelWithString: "") + private let sourcePopup = NSPopUpButton(frame: .zero, pullsDown: false) + private let sourceContainer = NSStackView() + private let sourceProfilesContainer = NSStackView() + private let sourceProfilesList = NSStackView() + private let sourceProfilesDocumentView = FlippedDocumentView(frame: .zero) + private let sourceProfilesEmptyLabel = NSTextField(wrappingLabelWithString: "") + private let sourceProfilesHelpLabel = NSTextField(labelWithString: "") + private let sourceProfilesScrollView = NSScrollView() + private let dataTypesContainer = NSStackView() + private let validationLabel = NSTextField(labelWithString: "") + private let destinationModeContainer = NSStackView() + private let separateProfilesRadio = NSButton(radioButtonWithTitle: "", target: nil, action: nil) + private let mergeProfilesRadio = NSButton(radioButtonWithTitle: "", target: nil, action: nil) + private let separateDestinationRows = NSStackView() + private let mergeDestinationRow = NSStackView() + private let mergeDestinationPopup = NSPopUpButton(frame: .zero, pullsDown: false) + private let destinationHelpLabel = NSTextField(wrappingLabelWithString: "") + + private let cookiesCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private let historyCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private let additionalDataCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private let domainField = NSTextField(frame: .zero) + + private let backButton = NSButton(title: "", target: nil, action: nil) + private let cancelButton = NSButton(title: "", target: nil, action: nil) + private let primaryButton = NSButton(title: "", target: nil, action: nil) + + init( + browsers: [InstalledBrowserCandidate], + destinationProfiles: [BrowserProfileDefinition]?, + defaultDestinationProfileID: UUID? + ) { + let resolvedDestinationProfiles = destinationProfiles ?? BrowserProfileStore.shared.profiles + let fallbackDestinationProfileID = resolvedDestinationProfiles.first?.id + ?? BrowserProfileStore.shared.effectiveLastUsedProfileID + self.browsers = browsers + self.destinationProfiles = resolvedDestinationProfiles + self.initialDestinationProfileID = defaultDestinationProfileID + .flatMap { candidateID in resolvedDestinationProfiles.first(where: { $0.id == candidateID })?.id } + ?? fallbackDestinationProfileID + self.mergeDestinationProfileID = self.initialDestinationProfileID + self.panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 620, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + super.init() + setupUI() + configureInitialState() + } + + func runModal() -> ImportSelection? { + panel.center() + panel.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + let response = NSApp.runModal(for: panel) + if panel.isVisible { + panel.orderOut(nil) + } + + guard response == .OK else { return nil } + return selection + } + + func windowWillClose(_ notification: Notification) { + finishModal(with: .cancel) + } + + @objc + private func handleBack() { + switch step { + case .source: + return + case .sourceProfiles: + step = .source + case .dataTypes: + step = .sourceProfiles + } + validationLabel.isHidden = true + updateStepUI() + } + + @objc + private func handleCancel() { + finishModal(with: .cancel) + } + + @objc + private func handlePrimary() { + switch step { + case .source: + step = .sourceProfiles + validationLabel.isHidden = true + refreshSourceProfilesList() + updateStepUI() + case .sourceProfiles: + let selectedSourceProfiles = selectedSourceProfiles() + guard !selectedSourceProfiles.isEmpty else { + validationLabel.stringValue = String( + localized: "browser.import.validation.sourceProfiles", + defaultValue: "Choose at least one source profile to import." + ) + validationLabel.isHidden = false + return + } + + resetStep3State() + step = .dataTypes + validationLabel.isHidden = true + updateStepUI() + case .dataTypes: + let includeCookies = cookiesCheckbox.state == .on + let includeHistory = historyCheckbox.state == .on + let includeAdditionalData = additionalDataCheckbox.state == .on + guard let scope = BrowserImportScope.fromSelection( + includeCookies: includeCookies, + includeHistory: includeHistory, + includeAdditionalData: includeAdditionalData + ) else { + validationLabel.stringValue = String( + localized: "browser.import.validation.scope", + defaultValue: "Select Cookies, History, or both before starting import." + ) + validationLabel.isHidden = false + return + } + + let selectedBrowser = selectedBrowser() + let domainFilters = BrowserDataImporter.parseDomainFilters(domainField.stringValue) + selection = ImportSelection( + browser: selectedBrowser, + executionPlan: currentExecutionPlan(), + scope: scope, + domainFilters: domainFilters + ) + finishModal(with: .OK) + } + } + + @objc + private func handleSourceChanged() { + validationLabel.isHidden = true + refreshSourceProfilesList() + updateStepUI() + } + + @objc + private func handleSourceProfileToggled(_ sender: NSButton) { + guard let profileID = sender.identifier?.rawValue else { return } + let browserID = selectedBrowser().id + var selectedIDs = storedSelectedSourceProfileIDs(for: selectedBrowser()) + if sender.state == .on { + selectedIDs.insert(profileID) + } else { + selectedIDs.remove(profileID) + } + selectedSourceProfileIDsByBrowserID[browserID] = selectedIDs + validationLabel.isHidden = true + } + + @objc + private func handleDestinationModeChanged(_ sender: NSButton) { + let selectedSourceProfiles = selectedSourceProfiles() + guard selectedSourceProfiles.count > 1 else { return } + destinationMode = sender == separateProfilesRadio ? .separateProfiles : .mergeIntoOne + rebuildStep3DestinationUI() + } + + @objc + private func handleMergeDestinationChanged(_ sender: NSPopUpButton) { + let selectedIndex = max(0, min(sender.indexOfSelectedItem, destinationProfiles.count - 1)) + guard destinationProfiles.indices.contains(selectedIndex) else { return } + mergeDestinationProfileID = destinationProfiles[selectedIndex].id + validationLabel.isHidden = true + } + + @objc + private func handleSeparateDestinationChanged(_ sender: NSPopUpButton) { + let entryIndex = sender.tag + guard separateExecutionEntries.indices.contains(entryIndex), + let options = separateDestinationOptionsByEntryIndex[entryIndex], + options.indices.contains(sender.indexOfSelectedItem) else { + return + } + separateExecutionEntries[entryIndex].destination = options[sender.indexOfSelectedItem] + validationLabel.isHidden = true + } + + private func setupUI() { + panel.title = String( + localized: "browser.import.title", + defaultValue: "Import Browser Data" + ) + panel.isReleasedWhenClosed = false + panel.delegate = self + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 620, height: 420)) + contentView.translatesAutoresizingMaskIntoConstraints = false + panel.contentView = contentView + + let titleLabel = NSTextField( + labelWithString: String( + localized: "browser.import.title", + defaultValue: "Import Browser Data" + ) + ) + titleLabel.font = NSFont.systemFont(ofSize: 24, weight: .semibold) + + stepLabel.font = NSFont.systemFont(ofSize: 15, weight: .medium) + stepLabel.textColor = .secondaryLabelColor + + setupSourceContainer() + setupSourceProfilesContainer() + setupDataTypesContainer() + + validationLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) + validationLabel.textColor = .systemRed + validationLabel.isHidden = true + validationLabel.lineBreakMode = .byWordWrapping + validationLabel.maximumNumberOfLines = 3 + + backButton.target = self + backButton.action = #selector(handleBack) + backButton.bezelStyle = .rounded + backButton.title = String(localized: "browser.import.back", defaultValue: "Back") + + cancelButton.target = self + cancelButton.action = #selector(handleCancel) + cancelButton.bezelStyle = .rounded + cancelButton.title = String(localized: "common.cancel", defaultValue: "Cancel") + cancelButton.keyEquivalent = "\u{1b}" + + primaryButton.target = self + primaryButton.action = #selector(handlePrimary) + primaryButton.bezelStyle = .rounded + primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next") + primaryButton.keyEquivalent = "\r" + + let buttonSpacer = NSView(frame: .zero) + + let buttonRow = NSStackView(views: [buttonSpacer, backButton, cancelButton, primaryButton]) + buttonRow.orientation = .horizontal + buttonRow.spacing = 8 + buttonRow.alignment = .centerY + buttonRow.translatesAutoresizingMaskIntoConstraints = false + buttonSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + buttonSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let contentStack = NSStackView(views: [ + titleLabel, + stepLabel, + sourceContainer, + sourceProfilesContainer, + dataTypesContainer, + validationLabel, + ]) + contentStack.orientation = .vertical + contentStack.spacing = 10 + contentStack.alignment = .leading + contentStack.translatesAutoresizingMaskIntoConstraints = false + + guard let panelContent = panel.contentView else { return } + panelContent.addSubview(contentStack) + panelContent.addSubview(buttonRow) + + NSLayoutConstraint.activate([ + contentStack.topAnchor.constraint(equalTo: panelContent.topAnchor, constant: 18), + contentStack.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 20), + contentStack.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -20), + + buttonRow.topAnchor.constraint(greaterThanOrEqualTo: contentStack.bottomAnchor, constant: 14), + buttonRow.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 20), + buttonRow.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -20), + buttonRow.bottomAnchor.constraint(equalTo: panelContent.bottomAnchor, constant: -16), + ]) + } + + private func setupSourceContainer() { + for browser in browsers { + sourcePopup.addItem(withTitle: browser.displayName) + } + sourcePopup.selectItem(at: 0) + sourcePopup.target = self + sourcePopup.action = #selector(handleSourceChanged) + + let sourceLabel = NSTextField( + labelWithString: String(localized: "browser.import.source", defaultValue: "Source") + ) + sourceLabel.alignment = .right + sourceLabel.frame.size.width = 80 + + let sourceRow = NSStackView(views: [sourceLabel, sourcePopup]) + sourceRow.orientation = .horizontal + sourceRow.spacing = 8 + sourceRow.alignment = .centerY + + let detectedLabel = NSTextField( + wrappingLabelWithString: InstalledBrowserDetector.summaryText(for: browsers) + ) + detectedLabel.font = NSFont.systemFont(ofSize: 12) + detectedLabel.textColor = .secondaryLabelColor + detectedLabel.maximumNumberOfLines = 2 + detectedLabel.preferredMaxLayoutWidth = 500 + + sourceContainer.orientation = .vertical + sourceContainer.spacing = 10 + sourceContainer.alignment = .leading + sourceContainer.addArrangedSubview(sourceRow) + sourceContainer.addArrangedSubview(detectedLabel) + } + + private func setupSourceProfilesContainer() { + let sourceProfilesTitle = NSTextField( + labelWithString: String( + localized: "browser.import.sourceProfiles", + defaultValue: "Source Profiles" + ) + ) + sourceProfilesTitle.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + + sourceProfilesList.orientation = .vertical + sourceProfilesList.spacing = 6 + sourceProfilesList.alignment = .leading + sourceProfilesList.translatesAutoresizingMaskIntoConstraints = false + + sourceProfilesEmptyLabel.font = NSFont.systemFont(ofSize: 13) + sourceProfilesEmptyLabel.textColor = .secondaryLabelColor + sourceProfilesEmptyLabel.maximumNumberOfLines = 0 + sourceProfilesEmptyLabel.preferredMaxLayoutWidth = 520 + + sourceProfilesDocumentView.frame = NSRect(x: 0, y: 0, width: 1, height: 1) + sourceProfilesDocumentView.translatesAutoresizingMaskIntoConstraints = false + sourceProfilesDocumentView.addSubview(sourceProfilesList) + NSLayoutConstraint.activate([ + sourceProfilesList.topAnchor.constraint(equalTo: sourceProfilesDocumentView.topAnchor), + sourceProfilesList.leadingAnchor.constraint(equalTo: sourceProfilesDocumentView.leadingAnchor), + sourceProfilesList.trailingAnchor.constraint(equalTo: sourceProfilesDocumentView.trailingAnchor), + sourceProfilesList.bottomAnchor.constraint(equalTo: sourceProfilesDocumentView.bottomAnchor), + sourceProfilesList.widthAnchor.constraint(equalTo: sourceProfilesDocumentView.widthAnchor), + ]) + + sourceProfilesScrollView.drawsBackground = false + sourceProfilesScrollView.borderType = .bezelBorder + sourceProfilesScrollView.hasVerticalScroller = true + sourceProfilesScrollView.documentView = sourceProfilesDocumentView + sourceProfilesScrollView.translatesAutoresizingMaskIntoConstraints = false + sourceProfilesScrollView.contentView.postsBoundsChangedNotifications = true + sourceProfilesScrollView.heightAnchor.constraint(equalToConstant: 180).isActive = true + + sourceProfilesHelpLabel.font = NSFont.systemFont(ofSize: 12) + sourceProfilesHelpLabel.textColor = .secondaryLabelColor + sourceProfilesHelpLabel.maximumNumberOfLines = 2 + sourceProfilesHelpLabel.lineBreakMode = .byWordWrapping + sourceProfilesHelpLabel.stringValue = String( + localized: "browser.import.sourceProfiles.help", + defaultValue: "Choose one or more source profiles. Step 3 lets you keep them separate or merge them into one cmux profile." + ) + + sourceProfilesContainer.orientation = .vertical + sourceProfilesContainer.spacing = 10 + sourceProfilesContainer.alignment = .leading + sourceProfilesContainer.addArrangedSubview(sourceProfilesTitle) + sourceProfilesContainer.addArrangedSubview(sourceProfilesScrollView) + sourceProfilesContainer.addArrangedSubview(sourceProfilesHelpLabel) + sourceProfilesContainer.setHuggingPriority(.defaultLow, for: .vertical) + sourceProfilesContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + } + + private func setupDataTypesContainer() { + cookiesCheckbox.state = .on + historyCheckbox.state = .on + additionalDataCheckbox.state = .off + cookiesCheckbox.title = String( + localized: "browser.import.cookies", + defaultValue: "Cookies (site sign-ins)" + ) + historyCheckbox.title = String( + localized: "browser.import.history", + defaultValue: "History (visited pages)" + ) + additionalDataCheckbox.title = String( + localized: "browser.import.additionalData", + defaultValue: "Additional data (bookmarks, settings, extensions)" + ) + cookiesCheckbox.setAccessibilityIdentifier("BrowserImportCookiesCheckbox") + historyCheckbox.setAccessibilityIdentifier("BrowserImportHistoryCheckbox") + additionalDataCheckbox.setAccessibilityIdentifier("BrowserImportAdditionalDataCheckbox") + separateProfilesRadio.title = String( + localized: "browser.import.destinationMode.separate", + defaultValue: "Keep profiles separate" + ) + mergeProfilesRadio.title = String( + localized: "browser.import.destinationMode.merge", + defaultValue: "Merge all into one cmux profile" + ) + separateProfilesRadio.target = self + separateProfilesRadio.action = #selector(handleDestinationModeChanged(_:)) + mergeProfilesRadio.target = self + mergeProfilesRadio.action = #selector(handleDestinationModeChanged(_:)) + + destinationModeContainer.orientation = .vertical + destinationModeContainer.spacing = 6 + destinationModeContainer.alignment = .leading + destinationModeContainer.addArrangedSubview(separateProfilesRadio) + destinationModeContainer.addArrangedSubview(mergeProfilesRadio) + + mergeDestinationPopup.target = self + mergeDestinationPopup.action = #selector(handleMergeDestinationChanged(_:)) + + separateDestinationRows.orientation = .vertical + separateDestinationRows.spacing = 8 + separateDestinationRows.alignment = .leading + + mergeDestinationRow.orientation = .horizontal + mergeDestinationRow.spacing = 8 + mergeDestinationRow.alignment = .centerY + + destinationHelpLabel.font = NSFont.systemFont(ofSize: 12) + destinationHelpLabel.textColor = .secondaryLabelColor + destinationHelpLabel.maximumNumberOfLines = 3 + destinationHelpLabel.preferredMaxLayoutWidth = 540 + + domainField.placeholderString = String( + localized: "browser.import.domain.placeholder", + defaultValue: "Optional domains only (e.g. github.com, openai.com)" + ) + domainField.stringValue = "" + + let destinationTitleLabel = NSTextField( + labelWithString: String( + localized: "browser.import.destination.cmux", + defaultValue: "cmux destination" + ) + ) + destinationTitleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + + let domainLabel = NSTextField( + labelWithString: String(localized: "browser.import.domain", defaultValue: "Limit to") + ) + domainLabel.alignment = .right + domainLabel.frame.size.width = 80 + + let domainRow = NSStackView(views: [domainLabel, domainField]) + domainRow.orientation = .horizontal + domainRow.spacing = 8 + domainRow.alignment = .centerY + + let noteLabel = NSTextField( + wrappingLabelWithString: String( + localized: "browser.import.additionalData.note", + defaultValue: "Bookmarks, settings, and extensions import are not available yet." + ) + ) + noteLabel.font = NSFont.systemFont(ofSize: 12) + noteLabel.textColor = .secondaryLabelColor + noteLabel.maximumNumberOfLines = 2 + noteLabel.preferredMaxLayoutWidth = 540 + + dataTypesContainer.orientation = .vertical + dataTypesContainer.spacing = 8 + dataTypesContainer.alignment = .leading + dataTypesContainer.addArrangedSubview(destinationTitleLabel) + dataTypesContainer.addArrangedSubview(destinationModeContainer) + dataTypesContainer.addArrangedSubview(separateDestinationRows) + dataTypesContainer.addArrangedSubview(mergeDestinationRow) + dataTypesContainer.addArrangedSubview(destinationHelpLabel) + dataTypesContainer.addArrangedSubview(cookiesCheckbox) + dataTypesContainer.addArrangedSubview(historyCheckbox) + dataTypesContainer.addArrangedSubview(additionalDataCheckbox) + dataTypesContainer.addArrangedSubview(domainRow) + dataTypesContainer.addArrangedSubview(noteLabel) + } + + private func configureInitialState() { + step = .source + refreshSourceProfilesList() + updateStepUI() + } + + private func updateStepUI() { + switch step { + case .source: + stepLabel.stringValue = String( + localized: "browser.import.step.source", + defaultValue: "Step 1 of 3: Choose the browser to import from." + ) + sourceContainer.isHidden = false + sourceProfilesContainer.isHidden = true + dataTypesContainer.isHidden = true + backButton.isHidden = true + primaryButton.isEnabled = true + primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next") + case .sourceProfiles: + stepLabel.stringValue = String( + format: String( + localized: "browser.import.step.sourceProfiles", + defaultValue: "Step 2 of 3: Choose source profiles from %@." + ), + selectedBrowser().displayName + ) + sourceContainer.isHidden = true + sourceProfilesContainer.isHidden = false + dataTypesContainer.isHidden = true + backButton.isHidden = false + primaryButton.isEnabled = !selectedBrowser().profiles.isEmpty + primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next") + case .dataTypes: + rebuildStep3DestinationUI() + stepLabel.stringValue = String( + format: String( + localized: "browser.import.step.dataTypes", + defaultValue: "Step 3 of 3: Choose what to import from %@ and where to put it." + ), + selectedBrowser().displayName + ) + sourceContainer.isHidden = true + sourceProfilesContainer.isHidden = true + dataTypesContainer.isHidden = false + backButton.isHidden = false + primaryButton.isEnabled = true + primaryButton.title = String( + localized: "browser.import.start", + defaultValue: "Start Import" + ) + } + } + + private func selectedBrowser() -> InstalledBrowserCandidate { + let selectedIndex = max(0, min(sourcePopup.indexOfSelectedItem, browsers.count - 1)) + return browsers[selectedIndex] + } + + private func refreshSourceProfilesList() { + let browser = selectedBrowser() + let selectedIDs = storedSelectedSourceProfileIDs(for: browser) + + sourceProfileCheckboxes.removeAll() + for arrangedSubview in sourceProfilesList.arrangedSubviews { + sourceProfilesList.removeArrangedSubview(arrangedSubview) + arrangedSubview.removeFromSuperview() + } + + if browser.profiles.isEmpty { + sourceProfilesEmptyLabel.stringValue = String( + format: String( + localized: "browser.import.sourceProfiles.empty", + defaultValue: "No source profiles detected for %@." + ), + browser.displayName + ) + sourceProfilesList.addArrangedSubview(sourceProfilesEmptyLabel) + return + } + + for profile in browser.profiles { + let checkbox = NSButton( + checkboxWithTitle: profile.displayName, + target: self, + action: #selector(handleSourceProfileToggled(_:)) + ) + checkbox.identifier = NSUserInterfaceItemIdentifier(profile.id) + checkbox.state = selectedIDs.contains(profile.id) ? .on : .off + checkbox.lineBreakMode = .byTruncatingTail + sourceProfilesList.addArrangedSubview(checkbox) + sourceProfileCheckboxes.append(checkbox) + } + } + + private func storedSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set { + if let existing = selectedSourceProfileIDsByBrowserID[browser.id] { + return existing + } + let defaultSelection = defaultSelectedSourceProfileIDs(for: browser) + selectedSourceProfileIDsByBrowserID[browser.id] = defaultSelection + return defaultSelection + } + + private func defaultSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set { + if let defaultProfile = browser.profiles.first(where: \.isDefault) { + return [defaultProfile.id] + } + if let firstProfile = browser.profiles.first { + return [firstProfile.id] + } + return [] + } + + private func selectedSourceProfiles() -> [InstalledBrowserProfile] { + let browser = selectedBrowser() + let selectedIDs = storedSelectedSourceProfileIDs(for: browser) + return browser.profiles.filter { selectedIDs.contains($0.id) } + } + + private func resetStep3State() { + let selectedProfiles = selectedSourceProfiles() + let defaultPlan = BrowserImportPlanResolver.defaultPlan( + selectedSourceProfiles: selectedProfiles, + destinationProfiles: destinationProfiles, + preferredSingleDestinationProfileID: initialDestinationProfileID + ) + destinationMode = defaultPlan.mode + separateExecutionEntries = BrowserImportPlanResolver.separateProfilesPlan( + selectedSourceProfiles: selectedProfiles, + destinationProfiles: destinationProfiles + ).entries + if let initialDestination = defaultPlan.entries.first.flatMap(destinationProfileID(for:)) { + mergeDestinationProfileID = initialDestination + } else { + mergeDestinationProfileID = initialDestinationProfileID + } + rebuildStep3DestinationUI() + } + + private func currentExecutionPlan() -> BrowserImportExecutionPlan { + let selectedProfiles = selectedSourceProfiles() + guard !selectedProfiles.isEmpty else { + return BrowserImportExecutionPlan(mode: .singleDestination, entries: []) + } + + guard selectedProfiles.count > 1 else { + return BrowserImportExecutionPlan( + mode: .singleDestination, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: selectedProfiles, + destination: .existing(resolvedMergeDestinationProfileID()) + ) + ] + ) + } + + switch destinationMode { + case .separateProfiles: + let entriesBySourceID = Dictionary( + uniqueKeysWithValues: separateExecutionEntries.compactMap { entry in + entry.sourceProfiles.first.map { ($0.id, entry.destination) } + } + ) + let entries = selectedProfiles.map { profile in + BrowserImportExecutionEntry( + sourceProfiles: [profile], + destination: entriesBySourceID[profile.id] ?? defaultSeparateDestinationRequest(for: profile) + ) + } + return BrowserImportExecutionPlan(mode: .separateProfiles, entries: entries) + case .singleDestination, .mergeIntoOne: + return BrowserImportExecutionPlan( + mode: .mergeIntoOne, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: selectedProfiles, + destination: .existing(resolvedMergeDestinationProfileID()) + ) + ] + ) + } + } + + private func rebuildStep3DestinationUI() { + let plan = currentExecutionPlan() + let presentation = BrowserImportStep3Presentation(plan: plan) + destinationModeContainer.isHidden = !presentation.showsModeSelector + separateDestinationRows.isHidden = !presentation.showsSeparateRows + mergeDestinationRow.isHidden = !presentation.showsSingleDestinationPicker + + if presentation.showsModeSelector { + separateProfilesRadio.state = destinationMode == .separateProfiles ? .on : .off + mergeProfilesRadio.state = destinationMode == .mergeIntoOne ? .on : .off + } else { + separateProfilesRadio.state = .off + mergeProfilesRadio.state = .off + } + + rebuildSeparateDestinationRows(with: plan) + rebuildMergeDestinationRow() + + if presentation.showsSeparateRows { + destinationHelpLabel.stringValue = String( + localized: "browser.import.destinationProfile.separateHelp", + defaultValue: "Missing cmux profiles are created when import starts." + ) + } else if plan.entries.count > 1 { + destinationHelpLabel.stringValue = String( + localized: "browser.import.destinationProfile.mergeHelp", + defaultValue: "All selected source profiles will be merged into the chosen cmux browser profile." + ) + } else { + destinationHelpLabel.stringValue = String( + localized: "browser.import.destinationProfile.help", + defaultValue: "Imported cookies and history go into the selected cmux browser profile." + ) + } + } + + private func rebuildSeparateDestinationRows(with plan: BrowserImportExecutionPlan) { + separateDestinationOptionsByEntryIndex.removeAll() + for arrangedSubview in separateDestinationRows.arrangedSubviews { + separateDestinationRows.removeArrangedSubview(arrangedSubview) + arrangedSubview.removeFromSuperview() + } + + guard plan.mode == .separateProfiles else { return } + + for (index, entry) in plan.entries.enumerated() { + guard let sourceProfile = entry.sourceProfiles.first else { continue } + let sourceLabel = NSTextField(labelWithString: sourceProfile.displayName) + sourceLabel.alignment = .right + sourceLabel.frame.size.width = 140 + + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.target = self + popup.action = #selector(handleSeparateDestinationChanged(_:)) + popup.tag = index + popup.setAccessibilityIdentifier( + "BrowserImportDestinationPopup-\(accessibilitySlug(for: sourceProfile, index: index))" + ) + + let options = destinationOptions(for: entry, sourceProfile: sourceProfile) + separateDestinationOptionsByEntryIndex[index] = options + for option in options { + popup.addItem(withTitle: title(for: option)) + } + if let selectedIndex = options.firstIndex(of: entry.destination) { + popup.selectItem(at: selectedIndex) + } else { + popup.selectItem(at: 0) + } + + let row = NSStackView(views: [sourceLabel, popup]) + row.orientation = .horizontal + row.spacing = 8 + row.alignment = .centerY + separateDestinationRows.addArrangedSubview(row) + } + } + + private func rebuildMergeDestinationRow() { + for arrangedSubview in mergeDestinationRow.arrangedSubviews { + mergeDestinationRow.removeArrangedSubview(arrangedSubview) + arrangedSubview.removeFromSuperview() + } + + mergeDestinationPopup.removeAllItems() + for profile in destinationProfiles { + mergeDestinationPopup.addItem(withTitle: profile.displayName) + } + if let selectedIndex = destinationProfiles.firstIndex(where: { $0.id == resolvedMergeDestinationProfileID() }) { + mergeDestinationPopup.selectItem(at: selectedIndex) + } else { + mergeDestinationPopup.selectItem(at: 0) + if let firstProfile = destinationProfiles.first { + mergeDestinationProfileID = firstProfile.id + } + } + mergeDestinationPopup.setAccessibilityIdentifier("BrowserImportDestinationPopup-merge") + + let destinationLabel = NSTextField( + labelWithString: String( + localized: "browser.import.destinationProfile", + defaultValue: "Import into" + ) + ) + destinationLabel.alignment = .right + destinationLabel.frame.size.width = 140 + + mergeDestinationRow.addArrangedSubview(destinationLabel) + mergeDestinationRow.addArrangedSubview(mergeDestinationPopup) + } + + private func destinationOptions( + for entry: BrowserImportExecutionEntry, + sourceProfile: InstalledBrowserProfile + ) -> [BrowserImportDestinationRequest] { + var options = destinationProfiles.map { BrowserImportDestinationRequest.existing($0.id) } + let createName: String + switch entry.destination { + case .createNamed(let name): + createName = name + case .existing: + createName = sourceProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + } + if !createName.isEmpty, + !destinationProfiles.contains(where: { + $0.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare(createName) == .orderedSame + }) { + options.append(.createNamed(createName)) + } + return options + } + + private func title(for request: BrowserImportDestinationRequest) -> String { + switch request { + case .existing(let id): + return destinationProfiles.first(where: { $0.id == id })?.displayName + ?? BrowserProfileStore.shared.displayName(for: id) + case .createNamed(let name): + return String( + format: String( + localized: "browser.import.destinationProfile.create", + defaultValue: "Create \"%@\"" + ), + name + ) + } + } + + private func destinationProfileID(for entry: BrowserImportExecutionEntry) -> UUID? { + guard case .existing(let id) = entry.destination else { return nil } + return id + } + + private func resolvedMergeDestinationProfileID() -> UUID { + if destinationProfiles.contains(where: { $0.id == mergeDestinationProfileID }) { + return mergeDestinationProfileID + } + return initialDestinationProfileID + } + + private func defaultSeparateDestinationRequest( + for profile: InstalledBrowserProfile + ) -> BrowserImportDestinationRequest { + BrowserImportPlanResolver.separateProfilesPlan( + selectedSourceProfiles: [profile], + destinationProfiles: destinationProfiles + ).entries.first?.destination ?? .createNamed(profile.displayName) + } + + private func accessibilitySlug(for profile: InstalledBrowserProfile, index: Int) -> String { + let base = profile.displayName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return base.isEmpty ? "profile-\(index)" : base + } + + private func finishModal(with response: NSApplication.ModalResponse) { + guard !didFinishModal else { return } + didFinishModal = true + + if NSApp.modalWindow == panel { + NSApp.stopModal(withCode: response) + } + panel.orderOut(nil) + } + } + + private func showProgressWindow(title: String, message: String) -> NSWindow { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 122), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.title = title + window.isReleasedWhenClosed = false + window.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + + let content = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 122)) + + let spinner = NSProgressIndicator(frame: NSRect(x: 20, y: 50, width: 20, height: 20)) + spinner.style = .spinning + spinner.controlSize = .regular + spinner.startAnimation(nil) + content.addSubview(spinner) + + let titleLabel = NSTextField(labelWithString: message) + titleLabel.frame = NSRect(x: 52, y: 56, width: 340, height: 20) + titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium) + content.addSubview(titleLabel) + + let subtitleLabel = NSTextField( + labelWithString: String( + localized: "browser.import.progress.subtitle", + defaultValue: "This can take a few seconds for large profiles." + ) + ) + subtitleLabel.frame = NSRect(x: 52, y: 34, width: 340, height: 16) + subtitleLabel.font = NSFont.systemFont(ofSize: 11) + subtitleLabel.textColor = .secondaryLabelColor + content.addSubview(subtitleLabel) + + window.contentView = content + + if let keyWindow = NSApp.keyWindow { + keyWindow.beginSheet(window, completionHandler: nil) + } else { + window.center() + window.makeKeyAndOrderFront(nil) + } + + return window + } + + private func hideProgressWindow(_ window: NSWindow) { + if let parent = window.sheetParent { + parent.endSheet(window) + } else { + window.orderOut(nil) + } + } + + private func presentOutcome(_ outcome: BrowserImportOutcome) { + let lines = BrowserImportOutcomeFormatter.lines(for: outcome) + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = String( + localized: "browser.import.complete.title", + defaultValue: "Browser data import complete" + ) + alert.informativeText = lines.joined(separator: "\n") + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + } +} diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 5c9c780a..596820de 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -203,9 +203,39 @@ func resolvedBrowserOmnibarPillBackgroundColor( return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor } +private struct BrowserChromeStyle { + let backgroundColor: NSColor + let colorScheme: ColorScheme + let omnibarPillBackgroundColor: NSColor + + static func resolve( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor + ) -> BrowserChromeStyle { + let backgroundColor = resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackgroundColor + ) + let chromeColorScheme = resolvedBrowserChromeColorScheme( + for: colorScheme, + themeBackgroundColor: backgroundColor + ) + let omnibarPillBackgroundColor = resolvedBrowserOmnibarPillBackgroundColor( + for: chromeColorScheme, + themeBackgroundColor: backgroundColor + ) + return BrowserChromeStyle( + backgroundColor: backgroundColor, + colorScheme: chromeColorScheme, + omnibarPillBackgroundColor: omnibarPillBackgroundColor + ) + } +} + /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel + @ObservedObject private var browserProfileStore = BrowserProfileStore.shared let paneId: PaneID let isFocused: Bool let isVisibleInUI: Bool @@ -220,10 +250,15 @@ struct BrowserPanelView: View { @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue + @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) + private var toggleBrowserDeveloperToolsShortcutData = Data() @State private var suggestionTask: Task? @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @State private var latestRemoteSuggestions: [String] = [] + @State private var emptyStateImportBrowsers: [InstalledBrowserCandidate] = [] + @State private var emptyStateImportBrowserRefreshTask: Task? + @State private var emptyStateImportBrowserRefreshGeneration: UInt64 = 0 @State private var inlineCompletion: OmnibarInlineCompletion? @State private var omnibarSelectionRange: NSRange = NSRange(location: NSNotFound, length: 0) @State private var omnibarHasMarkedText: Bool = false @@ -235,8 +270,13 @@ struct BrowserPanelView: View { @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var pendingAddressBarFocusRetryRequestId: UUID? @State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0 + @State private var isBrowserProfileMenuPresented = false @State private var isBrowserThemeMenuPresented = false - @State private var ghosttyBackgroundGeneration: Int = 0 + @State private var browserChromeStyle = BrowserChromeStyle.resolve( + for: .light, + themeBackgroundColor: GhosttyBackgroundTheme.currentColor() + ) + @State private var toggleBrowserDeveloperToolsShortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut // Keep this below half of the compact omnibar height so it reads as a squircle, // not a capsule. private let omnibarPillCornerRadius: CGFloat = 10 @@ -282,24 +322,15 @@ struct BrowserPanelView: View { } private var browserChromeBackground: Color { - _ = ghosttyBackgroundGeneration - return Color(nsColor: GhosttyBackgroundTheme.currentColor()) + Color(nsColor: browserChromeStyle.backgroundColor) } private var browserChromeBackgroundColor: NSColor { - _ = ghosttyBackgroundGeneration - return resolvedBrowserChromeBackgroundColor( - for: colorScheme, - themeBackgroundColor: GhosttyBackgroundTheme.currentColor() - ) + browserChromeStyle.backgroundColor } private var browserChromeColorScheme: ColorScheme { - _ = ghosttyBackgroundGeneration - return resolvedBrowserChromeColorScheme( - for: colorScheme, - themeBackgroundColor: GhosttyBackgroundTheme.currentColor() - ) + browserChromeStyle.colorScheme } private var browserContentAccessibilityIdentifier: String { @@ -307,10 +338,12 @@ struct BrowserPanelView: View { } private var omnibarPillBackgroundColor: NSColor { - resolvedBrowserOmnibarPillBackgroundColor( - for: browserChromeColorScheme, - themeBackgroundColor: browserChromeBackgroundColor - ) + browserChromeStyle.omnibarPillBackgroundColor + } + + private var developerToolsButtonHelp: String { + let base = String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools") + return "\(base) (\(toggleBrowserDeveloperToolsShortcut.displayString))" } private var owningWorkspace: Workspace? { @@ -420,6 +453,8 @@ struct BrowserPanelView: View { BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled, BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue, ]) + refreshBrowserChromeStyle() + refreshToggleBrowserDeveloperToolsShortcut() let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard) if browserThemeModeRaw != resolvedThemeMode.rawValue { browserThemeModeRaw = resolvedThemeMode.rawValue @@ -431,7 +466,8 @@ struct BrowserPanelView: View { // If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar. autoFocusOmnibarIfBlank() syncWebViewResponderPolicyWithViewState(reason: "onAppear") - BrowserHistoryStore.shared.loadIfNeeded() + refreshEmptyStateImportBrowsers() + panel.historyStore.loadIfNeeded() #if DEBUG logBrowserFocusState(event: "view.onAppear") #endif @@ -450,6 +486,9 @@ struct BrowserPanelView: View { !isWebViewBlank() { setAddressBarFocused(false, reason: "panel.currentURL.loaded") } + if isWebViewBlank() { + refreshEmptyStateImportBrowsers() + } } .onChange(of: browserThemeModeRaw) { _ in let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw) @@ -459,11 +498,21 @@ struct BrowserPanelView: View { panel.setBrowserThemeMode(normalizedMode) } .onChange(of: colorScheme) { _ in + refreshBrowserChromeStyle() panel.refreshAppearanceDrivenColors() } + .onChange(of: toggleBrowserDeveloperToolsShortcutData) { _ in + refreshToggleBrowserDeveloperToolsShortcut() + } .onChange(of: panel.pendingAddressBarFocusRequestId) { _ in applyPendingAddressBarFocusRequestIfNeeded() } + .onChange(of: panel.profileID) { _ in + panel.historyStore.loadIfNeeded() + if addressBarFocused { + refreshSuggestions() + } + } .onChange(of: isFocused) { focused in #if DEBUG logBrowserFocusState( @@ -536,7 +585,7 @@ struct BrowserPanelView: View { applyOmnibarEffects(effects) refreshInlineCompletion() } - .onReceive(BrowserHistoryStore.shared.$entries) { _ in + .onReceive(panel.historyStore.$entries) { _ in guard addressBarFocused else { return } refreshSuggestions() } @@ -552,7 +601,7 @@ struct BrowserPanelView: View { } } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - ghosttyBackgroundGeneration &+= 1 + refreshBrowserChromeStyle() } } @@ -564,10 +613,9 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") - if !panel.isShowingNewTabPage { - browserThemeModeButton - developerToolsButton - } + browserProfileButton + browserThemeModeButton + developerToolsButton } .padding(.horizontal, 8) .padding(.vertical, addressBarVerticalPadding) @@ -668,10 +716,38 @@ struct BrowserPanelView: View { } .buttonStyle(OmnibarAddressButtonStyle()) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) - .safeHelp(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip(String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools"))) + .safeHelp(developerToolsButtonHelp) .accessibilityIdentifier("BrowserToggleDevToolsButton") } + private var browserProfileButton: some View { + Button(action: { + isBrowserProfileMenuPresented.toggle() + }) { + Image(systemName: "person.crop.circle") + .symbolRenderingMode(.monochrome) + .cmuxFlatSymbolColorRendering() + .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .foregroundStyle(devToolsColorOption.color) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + } + .buttonStyle(OmnibarAddressButtonStyle()) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .popover(isPresented: $isBrowserProfileMenuPresented, arrowEdge: .bottom) { + browserProfilePopover + } + .safeHelp( + String( + format: String( + localized: "browser.profile.buttonHelp", + defaultValue: "Browser Profile: %@" + ), + panel.profileDisplayName + ) + ) + .accessibilityIdentifier("BrowserProfileButton") + } + private var browserThemeModeButton: some View { Button(action: { isBrowserThemeMenuPresented.toggle() @@ -688,10 +764,76 @@ struct BrowserPanelView: View { .popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) { browserThemeModePopover } - .safeHelp("Browser Theme: \(browserThemeMode.displayName)") + .safeHelp( + String( + format: String( + localized: "browser.theme.buttonHelp", + defaultValue: "Browser Theme: %@" + ), + browserThemeMode.displayName + ) + ) .accessibilityIdentifier("BrowserThemeModeButton") } + private var browserProfilePopover: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles")) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + ForEach(browserProfileStore.profiles) { profile in + Button { + applyBrowserProfileSelection(profile.id) + } label: { + HStack(spacing: 8) { + Image(systemName: profile.id == panel.profileID ? "checkmark" : "circle") + .font(.system(size: 10, weight: .semibold)) + .opacity(profile.id == panel.profileID ? 1.0 : 0.0) + .frame(width: 12, alignment: .center) + Text(profile.displayName) + .font(.system(size: 12)) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(profile.id == panel.profileID ? Color.primary.opacity(0.12) : Color.clear) + ) + } + .buttonStyle(.plain) + } + } + + Divider() + + Button { + isBrowserProfileMenuPresented = false + presentCreateBrowserProfilePrompt() + } label: { + Text(String(localized: "browser.profile.new", defaultValue: "New Profile...")) + .font(.system(size: 12)) + } + .buttonStyle(.plain) + + if browserProfileStore.canRenameProfile(id: panel.profileID) { + Button { + isBrowserProfileMenuPresented = false + presentRenameBrowserProfilePrompt() + } label: { + Text(String(localized: "browser.profile.rename", defaultValue: "Rename Current Profile...")) + .font(.system(size: 12)) + } + .buttonStyle(.plain) + } + } + .padding(8) + .frame(minWidth: 208) + } + private var browserThemeModePopover: some View { VStack(alignment: .leading, spacing: 2) { ForEach(BrowserThemeMode.allCases) { mode in @@ -876,6 +1018,11 @@ struct BrowserPanelView: View { setAddressBarFocused(false, reason: "placeholderContent.tapBlur") } } + .overlay { + if shouldShowEmptyStateImportOverlay { + emptyBrowserStateOverlay + } + } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -907,6 +1054,28 @@ struct BrowserPanelView: View { } } + private func refreshBrowserChromeStyle() { + browserChromeStyle = BrowserChromeStyle.resolve( + for: colorScheme, + themeBackgroundColor: GhosttyBackgroundTheme.currentColor() + ) + } + + private func refreshToggleBrowserDeveloperToolsShortcut() { + toggleBrowserDeveloperToolsShortcut = decodeShortcut( + from: toggleBrowserDeveloperToolsShortcutData, + fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut + ) + } + + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { + guard !data.isEmpty, + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return fallback + } + return shortcut + } + private func syncWebViewResponderPolicyWithViewState( reason: String, isPanelFocusedOverride: Bool? = nil @@ -1119,6 +1288,51 @@ struct BrowserPanelView: View { #endif } + private var emptyBrowserStateOverlay: some View { + VStack { + Spacer(minLength: 22) + + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.emptyImport.title", defaultValue: "Import browser data")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + + Text(InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) { + BrowserDataImportCoordinator.shared.presentImportDialog( + defaultDestinationProfileID: panel.profileID + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(12) + .frame(maxWidth: 360, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.9)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous).stroke( + Color(nsColor: .separatorColor).opacity(0.45), + lineWidth: 1 + ) + ) + .shadow(color: Color.black.opacity(0.08), radius: 8, y: 3) + + Spacer() + } + .padding(.horizontal, 18) + } + + private var shouldShowEmptyStateImportOverlay: Bool { + !panel.shouldRenderWebView && isWebViewBlank() + } + /// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes. private func isWebViewBlank() -> Bool { guard let url = panel.webView.url else { return true } @@ -1170,6 +1384,31 @@ struct BrowserPanelView: View { #endif } + private func refreshEmptyStateImportBrowsers() { + emptyStateImportBrowserRefreshTask?.cancel() + emptyStateImportBrowserRefreshGeneration &+= 1 + let generation = emptyStateImportBrowserRefreshGeneration + + guard shouldShowEmptyStateImportOverlay else { + emptyStateImportBrowsers = [] + emptyStateImportBrowserRefreshTask = nil + return + } + + emptyStateImportBrowserRefreshTask = Task { + let browsers = await Task.detached(priority: .utility) { + InstalledBrowserDetector.detectInstalledBrowsers() + }.value + guard !Task.isCancelled else { return } + await MainActor.run { + guard emptyStateImportBrowserRefreshGeneration == generation, + shouldShowEmptyStateImportOverlay else { return } + emptyStateImportBrowsers = browsers + emptyStateImportBrowserRefreshTask = nil + } + } + } + private func openDevTools() { #if DEBUG dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))") @@ -1273,10 +1512,73 @@ struct BrowserPanelView: View { let target = omnibarState.suggestions[idx] guard case .history(let url, _) = target.kind else { return } - guard BrowserHistoryStore.shared.removeHistoryEntry(urlString: url) else { return } + guard panel.historyStore.removeHistoryEntry(urlString: url) else { return } refreshSuggestions() } + private func applyBrowserProfileSelection(_ profileID: UUID) { + isBrowserProfileMenuPresented = false + owningWorkspace?.setPreferredBrowserProfileID(profileID) + _ = panel.switchToProfile(profileID) + } + + private func presentCreateBrowserProfilePrompt() { + let alert = NSAlert() + alert.messageText = String(localized: "browser.profile.new.title", defaultValue: "New Browser Profile") + alert.informativeText = String(localized: "browser.profile.new.message", defaultValue: "Create a separate browser profile for cookies, history, and local storage.") + + let input = NSTextField(string: "") + input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name") + input.frame = NSRect(x: 0, y: 0, width: 260, height: 22) + alert.accessoryView = input + + alert.addButton(withTitle: String(localized: "common.create", defaultValue: "Create")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + guard alert.runModal() == .alertFirstButtonReturn, + let profile = browserProfileStore.createProfile(named: input.stringValue) else { + return + } + + applyBrowserProfileSelection(profile.id) + } + + private func presentRenameBrowserProfilePrompt() { + guard let profile = browserProfileStore.profileDefinition(id: panel.profileID), + browserProfileStore.canRenameProfile(id: profile.id) else { + return + } + + let alert = NSAlert() + alert.messageText = String(localized: "browser.profile.rename.title", defaultValue: "Rename Browser Profile") + alert.informativeText = String(localized: "browser.profile.rename.message", defaultValue: "Choose a new name for this browser profile.") + + let input = NSTextField(string: profile.displayName) + input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name") + input.frame = NSRect(x: 0, y: 0, width: 260, height: 22) + alert.accessoryView = input + + alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + guard alert.runModal() == .alertFirstButtonReturn else { return } + _ = browserProfileStore.renameProfile(id: profile.id, to: input.stringValue) + } + private func refreshInlineCompletion() { inlineCompletion = omnibarInlineCompletionForDisplay( typedText: omnibarState.buffer, @@ -1312,9 +1614,9 @@ struct BrowserPanelView: View { let query = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines) let historyEntries: [BrowserHistoryStore.Entry] = { if query.isEmpty { - return BrowserHistoryStore.shared.recentSuggestions(limit: 12) + return panel.historyStore.recentSuggestions(limit: 12) } - return BrowserHistoryStore.shared.suggestions(for: query, limit: 12) + return panel.historyStore.suggestions(for: query, limit: 12) }() let openTabMatches = query.isEmpty ? [] : matchingOpenTabSuggestions(for: query, limit: 12) let isSingleCharacterQuery = omnibarSingleCharacterQuery(for: query) != nil @@ -1378,7 +1680,7 @@ struct BrowserPanelView: View { let merged = buildOmnibarSuggestions( query: query, engineName: searchEngine.displayName, - historyEntries: BrowserHistoryStore.shared.suggestions(for: query, limit: 12), + historyEntries: panel.historyStore.suggestions(for: query, limit: 12), openTabMatches: matchingOpenTabSuggestions(for: query, limit: 12), remoteQueries: remote, resolvedURL: panel.resolveNavigableURL(from: query), diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift new file mode 100644 index 00000000..692e6376 --- /dev/null +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -0,0 +1,619 @@ +import AppKit +import Bonsplit +import ObjectiveC +import WebKit + +func browserPopupContentRect( + requestedWidth: CGFloat?, + requestedHeight: CGFloat?, + requestedX: CGFloat?, + requestedTopY: CGFloat?, + visibleFrame: NSRect, + defaultWidth: CGFloat = 800, + defaultHeight: CGFloat = 600, + minWidth: CGFloat = 200, + minHeight: CGFloat = 150 +) -> NSRect { + let clampedWidth = min(max(requestedWidth ?? defaultWidth, minWidth), visibleFrame.width) + let clampedHeight = min(max(requestedHeight ?? defaultHeight, minHeight), visibleFrame.height) + + let x: CGFloat + let y: CGFloat + if let requestedX, let requestedTopY { + x = max(visibleFrame.minX, min(requestedX, visibleFrame.maxX - clampedWidth)) + + // Web content expresses popup Y as distance from the screen's top edge, + // while AppKit window origins are bottom-up. + let appKitY = visibleFrame.maxY - requestedTopY - clampedHeight + y = max(visibleFrame.minY, min(appKitY, visibleFrame.maxY - clampedHeight)) + } else { + x = visibleFrame.midX - clampedWidth / 2 + y = visibleFrame.midY - clampedHeight / 2 + } + + return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) +} + +/// Hosts a popup `CmuxWebView` in a standalone `NSPanel`, created when a page +/// calls `window.open()` (scripted new-window requests). +/// +/// Lifecycle: +/// - The controller self-retains via `objc_setAssociatedObject` on its panel. +/// - Released in `windowWillClose(_:)` when the panel closes. +/// - The opener `BrowserPanel` also keeps a strong reference for deterministic +/// cleanup when the opener tab or workspace is closed. +/// NSPanel subclass that intercepts Cmd+W before the swizzled +/// `cmux_performKeyEquivalent` can dispatch it to the main menu's +/// "Close Tab" action (which would close the parent browser tab). +private class BrowserPopupPanel: NSPanel { + override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Cmd+W: close this popup panel only + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == .command, + event.charactersIgnoringModifiers == "w" { + #if DEBUG + dlog("popup.panel.cmdW close") + #endif + performClose(nil) + return true + } + return super.performKeyEquivalent(with: event) + } +} + +@MainActor +final class BrowserPopupWindowController: NSObject, NSWindowDelegate { + + static let maxNestingDepth = 3 + + let webView: CmuxWebView + private let panel: NSPanel + private let urlLabel: NSTextField + private weak var openerPanel: BrowserPanel? + private weak var parentPopupController: BrowserPopupWindowController? + private let nestingDepth: Int + private var titleObservation: NSKeyValueObservation? + private var urlObservation: NSKeyValueObservation? + private var childPopups: [BrowserPopupWindowController] = [] + private let popupUIDelegate: PopupUIDelegate + private let popupNavigationDelegate: PopupNavigationDelegate + private let downloadDelegate: BrowserDownloadDelegate + + private static var associatedObjectKey: UInt8 = 0 + + init( + configuration: WKWebViewConfiguration, + windowFeatures: WKWindowFeatures, + openerPanel: BrowserPanel?, + parentPopupController: BrowserPopupWindowController? = nil, + nestingDepth: Int = 0 + ) { + self.openerPanel = openerPanel + self.parentPopupController = parentPopupController + self.nestingDepth = nestingDepth + + // Create popup web view with WebKit's supplied configuration (preserves + // internal browsing-context state for opener linkage / postMessage). + let webView = CmuxWebView(frame: .zero, configuration: configuration) + webView.allowsBackForwardNavigationGestures = true + if #available(macOS 13.3, *) { + webView.isInspectable = true + } + webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent + self.webView = webView + + // --- Window sizing from WKWindowFeatures --- + let defaultWidth: CGFloat = 800 + let defaultHeight: CGFloat = 600 + let minWidth: CGFloat = 200 + let minHeight: CGFloat = 150 + + let w = max(windowFeatures.width?.doubleValue ?? defaultWidth, minWidth) + let h = max(windowFeatures.height?.doubleValue ?? defaultHeight, minHeight) + + // Screen-clamping: use opener's screen or main screen + let screen = openerPanel?.webView.window?.screen ?? NSScreen.main ?? NSScreen.screens.first + let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let contentRect = browserPopupContentRect( + requestedWidth: w, + requestedHeight: h, + requestedX: windowFeatures.x.map { CGFloat($0.doubleValue) }, + requestedTopY: windowFeatures.y.map { CGFloat($0.doubleValue) }, + visibleFrame: visibleFrame, + defaultWidth: defaultWidth, + defaultHeight: defaultHeight, + minWidth: minWidth, + minHeight: minHeight + ) + + // Style mask: titled + closable + resizable by default. + // allowsResizing is a separate property from chrome-visibility flags + // (toolbarsVisibility, menuBarVisibility, statusBarVisibility). + var styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable] + if windowFeatures.allowsResizing?.boolValue != false { + styleMask.insert(.resizable) + } + + let panel = BrowserPopupPanel( + contentRect: contentRect, + styleMask: styleMask, + backing: .buffered, + defer: false + ) + panel.identifier = NSUserInterfaceItemIdentifier("cmux.browser-popup") + panel.level = NSWindow.Level.normal + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.minSize = NSSize(width: minWidth, height: minHeight) + panel.title = String(localized: "browser.popup.loadingTitle", defaultValue: "Loading\u{2026}") + self.panel = panel + + let urlLabel = NSTextField(labelWithString: "") + self.urlLabel = urlLabel + + // Build delegate objects before super.init so they can be assigned + let uiDel = PopupUIDelegate() + let navDel = PopupNavigationDelegate() + let dlDel = BrowserDownloadDelegate() + self.popupUIDelegate = uiDel + self.popupNavigationDelegate = navDel + self.downloadDelegate = dlDel + + super.init() + + // --- URL label for phishing protection --- + urlLabel.translatesAutoresizingMaskIntoConstraints = false + urlLabel.font = .systemFont(ofSize: 11) + urlLabel.textColor = .secondaryLabelColor + urlLabel.lineBreakMode = .byTruncatingMiddle + urlLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let containerView = NSView() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(urlLabel) + containerView.addSubview(webView) + webView.translatesAutoresizingMaskIntoConstraints = false + + panel.contentView = containerView + NSLayoutConstraint.activate([ + urlLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4), + urlLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8), + urlLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8), + urlLabel.heightAnchor.constraint(equalToConstant: 16), + + webView.topAnchor.constraint(equalTo: urlLabel.bottomAnchor, constant: 2), + webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + // --- Delegates --- + uiDel.controller = self + navDel.controller = self + navDel.downloadDelegate = dlDel + webView.uiDelegate = uiDel + webView.navigationDelegate = navDel + + // Context menu "Open Link in New Tab" → open in opener's workspace, + // not as a nested popup. Falls back to system browser if opener is gone. + webView.onContextMenuOpenLinkInNewTab = { [weak self] url in + if let opener = self?.openerPanel { + opener.openLinkInNewTab(url: url) + } else { + NSWorkspace.shared.open(url) + } + } + + // --- KVO for title and URL --- + titleObservation = webView.observe(\.title, options: [.new]) { [weak self] _, change in + guard let newTitle = change.newValue ?? nil, !newTitle.isEmpty else { return } + Task { @MainActor [weak self] in + self?.panel.title = newTitle + } + } + urlObservation = webView.observe(\.url, options: [.new]) { [weak self] _, change in + let displayURL = change.newValue??.absoluteString ?? "" + Task { @MainActor [weak self] in + self?.urlLabel.stringValue = displayURL + } + } + + // --- Self-retention via associated object on panel --- + objc_setAssociatedObject(panel, &Self.associatedObjectKey, self, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + panel.delegate = self + + #if DEBUG + dlog("popup.init depth=\(nestingDepth) size=\(Int(contentRect.width))x\(Int(contentRect.height)) opener=\(openerPanel?.id.uuidString.prefix(5) ?? "nil")") + #endif + + panel.makeKeyAndOrderFront(self) + } + + // MARK: - Child popup tracking + + func addChildPopup(_ child: BrowserPopupWindowController) { + childPopups.append(child) + } + + func removeChildPopup(_ child: BrowserPopupWindowController) { + childPopups.removeAll { $0 === child } + } + + // MARK: - Popup lifecycle + + func closePopup() { + panel.close() // triggers windowWillClose + } + + func closeAllChildPopups() { + let children = childPopups + childPopups.removeAll() + for child in children { + child.closeAllChildPopups() + child.closePopup() + } + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + #if DEBUG + dlog("popup.close depth=\(nestingDepth)") + #endif + + closeAllChildPopups() + + // Invalidate observations + titleObservation?.invalidate() + titleObservation = nil + urlObservation?.invalidate() + urlObservation = nil + + // Tear down web view + webView.stopLoading() + webView.navigationDelegate = nil + webView.uiDelegate = nil + + // Unregister from parent (opener panel or parent popup) + openerPanel?.removePopupController(self) + parentPopupController?.removeChildPopup(self) + + // Release self-retention + objc_setAssociatedObject(panel, &Self.associatedObjectKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + // MARK: - Nested popup creation + + func createNestedPopup( + configuration: WKWebViewConfiguration, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + let nextDepth = nestingDepth + 1 + if nextDepth > Self.maxNestingDepth { + #if DEBUG + dlog("popup.nested.blocked depth=\(nextDepth) max=\(Self.maxNestingDepth)") + #endif + return nil + } + let child = BrowserPopupWindowController( + configuration: configuration, + windowFeatures: windowFeatures, + openerPanel: openerPanel, + parentPopupController: self, + nestingDepth: nextDepth + ) + addChildPopup(child) + return child.webView + } + + func openInOpenerTab(_ url: URL) { + if let openerPanel { + openerPanel.openLinkInNewTab(url: url) + } else { + NSWorkspace.shared.open(url) + } + } + + // MARK: - Insecure HTTP prompt (parity with main browser) + + /// Shows the same 3-button insecure HTTP alert as the main browser. + /// Reuses the global helpers from BrowserPanel.swift. + fileprivate func presentInsecureHTTPAlert( + for url: URL, + in webView: WKWebView, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { + decisionHandler(.cancel) + return + } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") + alert.informativeText = String(localized: "browser.error.insecure.message", defaultValue: "\(host) uses plain HTTP, so traffic can be read or modified on the network.\n\nOpen this URL in your default browser, or proceed in cmux.") + alert.addButton(withTitle: String(localized: "browser.openInDefaultBrowser", defaultValue: "Open in Default Browser")) + alert.addButton(withTitle: String(localized: "browser.proceedInCmux", defaultValue: "Proceed in cmux")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.showsSuppressionButton = true + alert.suppressionButton?.title = String(localized: "browser.alwaysAllowHost", defaultValue: "Always allow this host in cmux") + + let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak alert] response in + if browserShouldPersistInsecureHTTPAllowlistSelection( + response: response, + suppressionEnabled: alert?.suppressionButton?.state == .on + ) { + BrowserInsecureHTTPSettings.addAllowedHost(host) + } + switch response { + case .alertFirstButtonReturn: + // Open in default browser, cancel popup navigation + NSWorkspace.shared.open(url) + decisionHandler(.cancel) + case .alertSecondButtonReturn: + // Proceed in popup + decisionHandler(.allow) + default: + decisionHandler(.cancel) + } + } + + if let window = webView.window { + alert.beginSheetModal(for: window, completionHandler: handleResponse) + return + } + handleResponse(alert.runModal()) + } +} + +// MARK: - PopupUIDelegate + +private class PopupUIDelegate: NSObject, WKUIDelegate { + weak var controller: BrowserPopupWindowController? + + func webViewDidClose(_ webView: WKWebView) { + #if DEBUG + dlog("popup.webViewDidClose") + #endif + controller?.closePopup() + } + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + // External URL check + if let url = navigationAction.request.url, + browserShouldOpenURLExternally(url) { + NSWorkspace.shared.open(url) + return nil + } + + let isScriptedPopup = browserNavigationShouldCreatePopup( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: CmuxWebView.hasRecentMiddleClickIntent(for: webView) + ) + + if isScriptedPopup { + return controller?.createNestedPopup( + configuration: configuration, + windowFeatures: windowFeatures + ) + } + + if let url = navigationAction.request.url { + controller?.openInOpenerTab(url) + } + return nil + } + + // MARK: - JS Dialogs (parity with main browser) + + private func javaScriptDialogTitle(for webView: WKWebView) -> String { + if let absolute = webView.url?.absoluteString, !absolute.isEmpty { + return String(localized: "browser.dialog.pageSaysAt", defaultValue: "The page at \(absolute) says:") + } + return String(localized: "browser.dialog.pageSays", defaultValue: "This page says:") + } + + private func presentDialog( + _ alert: NSAlert, + for webView: WKWebView, + completion: @escaping (NSApplication.ModalResponse) -> Void + ) { + if let window = webView.window { + alert.beginSheetModal(for: window, completionHandler: completion) + return + } + completion(alert.runModal()) + } + + func webView( + _ webView: WKWebView, + runJavaScriptAlertPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping () -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = message + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + presentDialog(alert, for: webView) { _ in completionHandler() } + } + + func webView( + _ webView: WKWebView, + runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (Bool) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = message + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + presentDialog(alert, for: webView) { response in + completionHandler(response == .alertFirstButtonReturn) + } + } + + func webView( + _ webView: WKWebView, + runJavaScriptTextInputPanelWithPrompt prompt: String, + defaultText: String?, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (String?) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = prompt + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) + field.stringValue = defaultText ?? "" + alert.accessoryView = field + + presentDialog(alert, for: webView) { response in + if response == .alertFirstButtonReturn { + completionHandler(field.stringValue) + } else { + completionHandler(nil) + } + } + } + + func webView( + _ webView: WKWebView, + runOpenPanelWith parameters: WKOpenPanelParameters, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping ([URL]?) -> Void + ) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = parameters.allowsMultipleSelection + panel.canChooseDirectories = parameters.allowsDirectories + panel.canChooseFiles = true + panel.begin { result in + completionHandler(result == .OK ? panel.urls : nil) + } + } + + func webView( + _ webView: WKWebView, + requestMediaCapturePermissionFor origin: WKSecurityOrigin, + initiatedByFrame frame: WKFrameInfo, + type: WKMediaCaptureType, + decisionHandler: @escaping (WKPermissionDecision) -> Void + ) { + decisionHandler(.prompt) + } +} + +// MARK: - PopupNavigationDelegate + +private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { + weak var controller: BrowserPopupWindowController? + var downloadDelegate: WKDownloadDelegate? + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + // Only guard main-frame navigations + guard navigationAction.targetFrame?.isMainFrame != false else { + decisionHandler(.allow) + return + } + + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + // External URL schemes → hand off to macOS + if browserShouldOpenURLExternally(url) { + NSWorkspace.shared.open(url) + #if DEBUG + dlog("popup.nav.external url=\(url.absoluteString)") + #endif + decisionHandler(.cancel) + return + } + + // Insecure HTTP → show same prompt as main browser + if browserShouldBlockInsecureHTTPURL(url) { + #if DEBUG + dlog("popup.nav.insecureHTTP url=\(url.absoluteString)") + #endif + controller?.presentInsecureHTTPAlert(for: url, in: webView, decisionHandler: decisionHandler) + return + } + + decisionHandler(.allow) + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + if !navigationResponse.isForMainFrame { + decisionHandler(.allow) + return + } + + if let scheme = navigationResponse.response.url?.scheme?.lowercased(), + scheme != "http", scheme != "https" { + decisionHandler(.allow) + return + } + + if let response = navigationResponse.response as? HTTPURLResponse { + let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? "" + if contentDisposition.lowercased().hasPrefix("attachment") { + decisionHandler(.download) + return + } + } + + if !navigationResponse.canShowMIMEType { + decisionHandler(.download) + return + } + + decisionHandler(.allow) + } + + func webView( + _ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + // Parity with main browser: performDefaultHandling enables system keychain + // lookups, MDM client certs, and SSO extensions (e.g. Microsoft Entra ID). + completionHandler(.performDefaultHandling, nil) + } + + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + #if DEBUG + dlog("popup.download.didBecome source=navigationAction") + #endif + download.delegate = downloadDelegate + } + + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + #if DEBUG + dlog("popup.download.didBecome source=navigationResponse") + #endif + download.delegate = downloadDelegate + } +} diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index aaf751d9..8990e685 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -53,6 +53,9 @@ final class CmuxWebView: WKWebView { private static var contextMenuFallbackKey: UInt8 = 0 var onContextMenuDownloadStateChanged: ((Bool) -> Void)? + /// Called when "Open Link in New Tab" context menu is selected. + /// Bypasses createWebViewWith so the link opens as a tab, not a popup. + var onContextMenuOpenLinkInNewTab: ((URL) -> Void)? var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)? var contextMenuDefaultBrowserOpener: ((URL) -> Bool)? /// Guard against background panes stealing first responder (e.g. page autofocus). @@ -1212,12 +1215,15 @@ final class CmuxWebView: WKWebView { openLinkInsertionIndex = index + 1 } - // Rename "Open Link in New Window" to "Open Link in New Tab". - // The UIDelegate's createWebViewWith already handles the action - // by opening the link as a new surface in the same pane. + // Retarget "Open Link in New Window" to open as a tab, not a popup. + // Without this, WebKit's default action calls createWebViewWith with + // navigationType .other, which our classifier would treat as a scripted + // popup request. if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow" || item.title.contains("Open Link in New Window") { item.title = String(localized: "browser.contextMenu.openLinkInNewTab", defaultValue: "Open Link in New Tab") + item.target = self + item.action = #selector(contextMenuOpenLinkInNewTab(_:)) } if isDownloadImageMenuItem(item) { @@ -1275,6 +1281,14 @@ final class CmuxWebView: WKWebView { } } + @objc private func contextMenuOpenLinkInNewTab(_ sender: Any?) { + let point = lastContextMenuPoint + resolveContextMenuLinkURL(at: point) { [weak self] url in + guard let self, let url else { return } + self.onContextMenuOpenLinkInNewTab?(url) + } + } + @objc private func contextMenuDownloadImage(_ sender: Any?) { let traceID = Self.makeContextDownloadTraceID(prefix: "img") let point = lastContextMenuPoint diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 53eb995e..b0303d53 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -8,7 +8,7 @@ enum SessionSnapshotSchema { enum SessionPersistencePolicy { static let defaultSidebarWidth: Double = 200 - static let minimumSidebarWidth: Double = 186 + static let minimumSidebarWidth: Double = 180 static let maximumSidebarWidth: Double = 600 static let minimumWindowWidth: Double = 300 static let minimumWindowHeight: Double = 200 @@ -228,6 +228,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { struct SessionBrowserPanelSnapshot: Codable, Sendable { var urlString: String? + var profileID: UUID? var shouldRenderWebView: Bool var pageZoom: Double var developerToolsVisible: Bool diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index d2340739..cca457ab 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -636,6 +636,15 @@ class TabManager: ObservableObject { private struct InitialWorkspaceGitMetadataSnapshot: Equatable { let branch: String? let isDirty: Bool + let pullRequest: SidebarPullRequestState? + } + + private struct CommandResult { + let stdout: String? + let stderr: String? + let exitStatus: Int32? + let timedOut: Bool + let executionError: String? } /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). @@ -651,6 +660,7 @@ class TabManager: ObservableObject { /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). private static var nextPortOrdinal: Int = 0 private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] + private nonisolated static let initialWorkspacePullRequestProbeTimeout: TimeInterval = 5.0 @Published var selectedTabId: UUID? { willSet { #if DEBUG @@ -760,6 +770,7 @@ class TabManager: ObservableObject { return tabs.first(where: { $0.id == selectedTabId }) } } + private var agentPIDSweepTimer: DispatchSourceTimer? #if DEBUG private var debugWorkspaceSwitchCounter: UInt64 = 0 private var debugWorkspaceSwitchId: UInt64 = 0 @@ -805,6 +816,8 @@ class TabManager: ObservableObject { } }) + startAgentPIDSweepTimer() + #if DEBUG setupUITestFocusShortcutsIfNeeded() setupSplitCloseRightUITestIfNeeded() @@ -815,6 +828,54 @@ class TabManager: ObservableObject { deinit { workspaceCycleCooldownTask?.cancel() + agentPIDSweepTimer?.cancel() + } + + // MARK: - Agent PID Sweep + + /// Periodically checks agent PIDs associated with status entries. + /// If a process has exited (SIGKILL, crash, etc.), clears the stale status entry. + /// This is the safety net for cases where no hook fires (e.g. SIGKILL). + private func startAgentPIDSweepTimer() { + let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) + timer.schedule(deadline: .now() + 30, repeating: 30) + timer.setEventHandler { [weak self] in + guard let self else { return } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.sweepStaleAgentPIDs() + } + } + timer.resume() + agentPIDSweepTimer = timer + } + + private func sweepStaleAgentPIDs() { + for tab in tabs { + var keysToRemove: [String] = [] + for (key, pid) in tab.agentPIDs { + guard pid > 0 else { + keysToRemove.append(key) + continue + } + // kill(pid, 0) probes process liveness without sending a signal. + // ESRCH = process doesn't exist (stale). EPERM = process exists + // but we lack permission (not stale, keep tracking). + errno = 0 + if kill(pid, 0) == -1, POSIXErrorCode(rawValue: errno) == .ESRCH { + keysToRemove.append(key) + } + } + if !keysToRemove.isEmpty { + for key in keysToRemove { + tab.statusEntries.removeValue(forKey: key) + tab.agentPIDs.removeValue(forKey: key) + } + // Also clear stale notifications (e.g. "Doing well, thanks!") + // left behind when Claude was killed without SessionEnd firing. + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id) + } + } } private func wireClosedBrowserTracking(for workspace: Workspace) { @@ -865,6 +926,20 @@ class TabManager: ObservableObject { _ = panel.performBindingAction("start_search") return } + if let panel = selectedTerminalPanel { + let hadExistingSearch = panel.searchState != nil + let handled = startOrFocusTerminalSearch(panel.surface) + NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) +#if DEBUG + dlog( + "find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " + + "panel=\(panel.id.uuidString.prefix(5)) existing=\(hadExistingSearch ? "yes" : "no") " + + "handled=\(handled ? 1 : 0) " + + "firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))" + ) +#endif + return + } focusedBrowserPanel?.startFind() } @@ -1118,15 +1193,25 @@ class TabManager: ObservableObject { workspace.clearPanelGitBranch(panelId: panelId) } - if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) { + if let pullRequest = snapshot.pullRequest { + workspace.updatePanelPullRequest( + panelId: panelId, + number: pullRequest.number, + label: pullRequest.label, + url: pullRequest.url, + status: pullRequest.status + ) + } else if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) { workspace.clearPanelPullRequest(panelId: panelId) } #if DEBUG let branchLabel = snapshot.branch ?? "none" + let prLabel = snapshot.pullRequest.map { "#\($0.number):\($0.status.rawValue)" } ?? "none" dlog( "workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)" + "panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " + + "pr=\(prLabel)" ) #endif } @@ -1136,36 +1221,233 @@ class TabManager: ObservableObject { ) -> InitialWorkspaceGitMetadataSnapshot { let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"])) guard let branch else { - return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false) + return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false, pullRequest: nil) } let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty) + let pullRequest = initialWorkspacePullRequestSnapshot(directory: directory, branch: branch) + return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest) } private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? { + runCommand( + directory: directory, + executable: "git", + arguments: arguments + ) + } + + private nonisolated static func initialWorkspacePullRequestSnapshot( + directory: String, + branch: String + ) -> SidebarPullRequestState? { + let repoSlug = githubRepositorySlug(directory: directory) + let repoArguments = repoSlug.map { ["--repo", $0] } ?? [] + let result = runCommandResult( + directory: directory, + executable: "gh", + arguments: [ + "pr", "view", branch, + ] + repoArguments + [ + "--json", "number,state,url", + "--jq", "[.number, .state, .url] | @tsv", + ], + timeout: initialWorkspacePullRequestProbeTimeout + ) + + guard let result else { return nil } + guard let output = result.stdout, + result.exitStatus == 0, + !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { +#if DEBUG + let statusText: String + if result.timedOut { + statusText = "timeout" + } else if let exitStatus = result.exitStatus { + statusText = "exit=\(exitStatus)" + } else if let executionError = result.executionError { + statusText = "error=\(executionError)" + } else { + statusText = "unknown" + } + let stderr = debugLogSnippet(result.stderr) ?? "none" + dlog( + "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug ?? "none") status=\(statusText) stderr=\(stderr)" + ) +#endif + return nil + } + + let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) + let fields = trimmedOutput + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "\t", maxSplits: 2, omittingEmptySubsequences: false) + guard fields.count == 3, + let number = Int(fields[0]), + let url = URL(string: String(fields[2])) else { +#if DEBUG + dlog( + "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug ?? "none") output=\(debugLogSnippet(trimmedOutput) ?? "none")" + ) +#endif + return nil + } + + let status: SidebarPullRequestStatus + switch fields[1].uppercased() { + case "OPEN": + status = .open + case "MERGED": + status = .merged + case "CLOSED": + status = .closed + default: + return nil + } + +#if DEBUG + dlog( + "workspace.gitProbe.pr.success dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug ?? "none") number=\(number) state=\(status.rawValue)" + ) +#endif + return SidebarPullRequestState(number: number, label: "PR", url: url, status: status) + } + + private nonisolated static func runCommand( + directory: String, + executable: String, + arguments: [String], + timeout: TimeInterval? = nil + ) -> String? { + let result = runCommandResult( + directory: directory, + executable: executable, + arguments: arguments, + timeout: timeout + ) + guard let result, + result.exitStatus == 0, + !result.timedOut else { + return nil + } + return result.stdout + } + + private nonisolated static func runCommandResult( + directory: String, + executable: String, + arguments: [String], + timeout: TimeInterval? = nil + ) -> CommandResult? { let process = Process() let stdout = Pipe() + let stderr = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["git", "-C", directory] + arguments + process.arguments = [executable] + arguments + process.currentDirectoryURL = URL(fileURLWithPath: directory) process.standardOutput = stdout - process.standardError = FileHandle.nullDevice + process.standardError = stderr + + let completion = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + completion.signal() + } do { try process.run() } catch { + return CommandResult( + stdout: nil, + stderr: nil, + exitStatus: nil, + timedOut: false, + executionError: String(describing: error) + ) + } + + if let timeout, + completion.wait(timeout: .now() + timeout) == .timedOut { + process.terminate() + if completion.wait(timeout: .now() + 0.2) == .timedOut { + kill(process.processIdentifier, SIGKILL) + _ = completion.wait(timeout: .now() + 0.2) + } + return CommandResult( + stdout: nil, + stderr: nil, + exitStatus: nil, + timedOut: true, + executionError: nil + ) + } else if timeout == nil { + completion.wait() + } + + let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderr.fileHandleForReading.readDataToEndOfFile() + return CommandResult( + stdout: String(data: stdoutData, encoding: .utf8), + stderr: String(data: stderrData, encoding: .utf8), + exitStatus: process.terminationStatus, + timedOut: false, + executionError: nil + ) + } + + private nonisolated static func githubRepositorySlug(directory: String) -> String? { + guard let remoteURL = runGitCommand( + directory: directory, + arguments: ["remote", "get-url", "origin"] + ) else { return nil } - // Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer. - let data = stdout.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0 else { + let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let githubPrefixes = [ + "git@github.com:", + "ssh://git@github.com/", + "https://github.com/", + "http://github.com/", + "git://github.com/", + ] + for prefix in githubPrefixes where trimmed.hasPrefix(prefix) { + let path = String(trimmed.dropFirst(prefix.count)) + return normalizedGitHubRepositorySlug(path) + } + + guard let url = URL(string: trimmed), + let host = url.host?.lowercased(), + host == "github.com" else { return nil } - return String(data: data, encoding: .utf8) + return normalizedGitHubRepositorySlug(url.path) + } + + private nonisolated static func normalizedGitHubRepositorySlug(_ rawPath: String) -> String? { + let trimmedPath = rawPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !trimmedPath.isEmpty else { return nil } + let components = trimmedPath.split(separator: "/").map(String.init) + guard components.count >= 2 else { return nil } + let owner = components[0] + var repo = components[1] + if repo.hasSuffix(".git") { + repo.removeLast(4) + } + guard !owner.isEmpty, !repo.isEmpty else { return nil } + return "\(owner)/\(repo)" + } + + private nonisolated static func debugLogSnippet(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return String(trimmed.prefix(180)) } private nonisolated static func normalizedBranchName(_ branch: String?) -> String? { @@ -1353,10 +1635,11 @@ class TabManager: ObservableObject { guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false } if tabs.count <= 1 { return true } - let clamped = max(0, min(targetIndex, tabs.count - 1)) + let workspace = tabs[currentIndex] + let clamped = clampedReorderIndex(for: workspace, targetIndex: targetIndex) if currentIndex == clamped { return true } - let workspace = tabs.remove(at: currentIndex) + tabs.remove(at: currentIndex) tabs.insert(workspace, at: clamped) return true } @@ -1412,6 +1695,15 @@ class TabManager: ObservableObject { tabs.insert(tab, at: insertIndex) } + private func clampedReorderIndex(for workspace: Workspace, targetIndex: Int) -> Int { + let clamped = max(0, min(targetIndex, tabs.count - 1)) + let pinnedCount = tabs.filter { $0.isPinned }.count + if workspace.isPinned { + return min(clamped, max(0, pinnedCount - 1)) + } + return max(clamped, pinnedCount) + } + // MARK: - Surface Directory Updates (Backwards Compatibility) func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) { @@ -2686,6 +2978,7 @@ class TabManager: ObservableObject { orientation: SplitOrientation, insertFirst: Bool = false, url: URL? = nil, + preferredProfileID: UUID? = nil, focus: Bool = true ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } @@ -2694,14 +2987,24 @@ class TabManager: ObservableObject { orientation: orientation, insertFirst: insertFirst, url: url, + preferredProfileID: preferredProfileID, focus: focus )?.id } /// Create a new browser surface in a pane - func newBrowserSurface(tabId: UUID, inPane paneId: PaneID, url: URL? = nil) -> UUID? { + func newBrowserSurface( + tabId: UUID, + inPane paneId: PaneID, + url: URL? = nil, + preferredProfileID: UUID? = nil + ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - return tab.newBrowserSurface(inPane: paneId, url: url)?.id + return tab.newBrowserSurface( + inPane: paneId, + url: url, + preferredProfileID: preferredProfileID + )?.id } /// Get a browser panel by ID @@ -2716,6 +3019,7 @@ class TabManager: ObservableObject { inWorkspace tabId: UUID, url: URL? = nil, preferSplitRight: Bool = false, + preferredProfileID: UUID? = nil, insertAtEnd: Bool = false ) -> UUID? { guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil } @@ -2729,7 +3033,8 @@ class TabManager: ObservableObject { inPane: targetPaneId, url: url, focus: true, - insertAtEnd: insertAtEnd + insertAtEnd: insertAtEnd, + preferredProfileID: preferredProfileID ) { rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) return browserPanel.id @@ -2755,6 +3060,7 @@ class TabManager: ObservableObject { from: splitSourcePanelId, orientation: .horizontal, url: url, + preferredProfileID: preferredProfileID, focus: true ) { rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) @@ -2767,7 +3073,8 @@ class TabManager: ObservableObject { inPane: paneId, url: url, focus: true, - insertAtEnd: insertAtEnd + insertAtEnd: insertAtEnd, + preferredProfileID: preferredProfileID ) else { return nil } @@ -2777,12 +3084,17 @@ class TabManager: ObservableObject { /// Open a browser in the currently focused pane (as a new surface) @discardableResult - func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { + func openBrowser( + url: URL? = nil, + preferredProfileID: UUID? = nil, + insertAtEnd: Bool = false + ) -> UUID? { guard let tabId = selectedTabId else { return nil } return openBrowser( inWorkspace: tabId, url: url, preferSplitRight: false, + preferredProfileID: preferredProfileID, insertAtEnd: insertAtEnd ) } @@ -2878,7 +3190,12 @@ class TabManager: ObservableObject { in workspace: Workspace ) -> UUID? { if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }), - let browserPanel = workspace.newBrowserSurface(inPane: originalPane, url: snapshot.url, focus: true) { + let browserPanel = workspace.newBrowserSurface( + inPane: originalPane, + url: snapshot.url, + focus: true, + preferredProfileID: snapshot.profileID + ) { let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count let maxIndex = max(0, tabCount - 1) let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex) @@ -2895,7 +3212,8 @@ class TabManager: ObservableObject { from: anchorPanelId, orientation: orientation, insertFirst: snapshot.fallbackSplitInsertFirst, - url: snapshot.url + url: snapshot.url, + preferredProfileID: snapshot.profileID )?.id { return browserPanelId } @@ -2903,7 +3221,12 @@ class TabManager: ObservableObject { guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { return nil } - return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true)?.id + return workspace.newBrowserSurface( + inPane: focusedPane, + url: snapshot.url, + focus: true, + preferredProfileID: snapshot.profileID + )?.id } /// Flash the currently focused panel so the user can visually confirm focus. @@ -4081,6 +4404,9 @@ extension TabManager { for tab in tabs { unwireClosedBrowserTracking(for: tab) } + for workspaceId in Array(initialWorkspaceGitProbeGenerationByWorkspace.keys) { + clearInitialWorkspaceGitProbe(workspaceId: workspaceId) + } // Clear non-@Published state without touching tabs/selectedTabId yet. lastFocusedPanelByTab.removeAll() @@ -4137,6 +4463,21 @@ extension TabManager { // never see an intermediate state with empty tabs or nil selection. tabs = newTabs selectedTabId = newSelectedId + for workspace in newTabs { + guard let terminalPanel = workspace.focusedTerminalPanel ?? workspace.panels.values + .compactMap({ $0 as? TerminalPanel }) + .first, + let directory = normalizedWorkingDirectory( + workspace.panelDirectories[terminalPanel.id] ?? workspace.currentDirectory + ) else { + continue + } + scheduleInitialWorkspaceGitMetadataRefresh( + workspaceId: workspace.id, + panelId: terminalPanel.id, + directory: directory + ) + } if let selectedTabId { NotificationCenter.default.post( diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index e75bf770..0a94df81 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -60,6 +60,9 @@ class TerminalController { private nonisolated static let socketProbePollTimeoutMs: Int32 = 100 private nonisolated static let socketProbePollAttempts = 3 private nonisolated static let socketProbePollRetryBackoffUs: useconds_t = 50_000 + private nonisolated static let socketListenerFailureCaptureCooldown: TimeInterval = 60 + private nonisolated static let socketListenerFailureCaptureLock = NSLock() + private nonisolated(unsafe) static var socketListenerFailureLastCapturedAt: [String: Date] = [:] private nonisolated static let unixSocketPathMaxLength: Int = { var addr = sockaddr_un() // Reserve one byte for the null terminator. @@ -555,9 +558,35 @@ class TerminalController { ) { let data = socketListenerEventData(stage: stage, errnoCode: errnoCode, extra: extra) sentryBreadcrumb(message, category: "socket", data: data) + guard Self.shouldCaptureSocketListenerFailure( + message: message, + stage: stage, + path: data["path"] as? String ?? "", + errnoCode: errnoCode + ) else { + return + } sentryCaptureError(message, category: "socket", data: data, contextKey: "socket_listener") } + private nonisolated static func shouldCaptureSocketListenerFailure( + message: String, + stage: String, + path: String, + errnoCode: Int32? + ) -> Bool { + let key = "\(message)|\(stage)|\(path)|\(errnoCode.map(String.init) ?? "none")" + let now = Date() + socketListenerFailureCaptureLock.lock() + defer { socketListenerFailureCaptureLock.unlock() } + if let lastCapturedAt = socketListenerFailureLastCapturedAt[key], + now.timeIntervalSince(lastCapturedAt) < socketListenerFailureCaptureCooldown { + return false + } + socketListenerFailureLastCapturedAt[key] = now + return true + } + nonisolated static func acceptErrorClassification(errnoCode: Int32) -> String { switch errnoCode { case EINTR, ECONNABORTED, EAGAIN, EWOULDBLOCK: @@ -1572,6 +1601,12 @@ class TerminalController { case "clear_status": return clearStatus(args) + case "set_agent_pid": + return setAgentPID(args) + + case "clear_agent_pid": + return clearAgentPID(args) + case "clear_meta": return clearMeta(args) @@ -13394,6 +13429,61 @@ class TerminalController { return trimmed.isEmpty ? nil : trimmed } + private func schedulePanelMetadataMutation( + args: String, + options: [String: String], + missingPanelUsage: String, + mutation: @escaping (Tab, UUID) -> Void + ) -> String { + let rawPanelArg = options["panel"] ?? options["surface"] + let surfaceIdFromOptions: UUID? + if let rawPanelArg { + if rawPanelArg.isEmpty { + return "ERROR: Missing panel id — usage: \(missingPanelUsage)" + } + guard let surfaceId = UUID(uuidString: rawPanelArg) else { + return "ERROR: Invalid panel id '\(rawPanelArg)'" + } + surfaceIdFromOptions = surfaceId + } else { + surfaceIdFromOptions = nil + } + + if let tabArg = options["tab"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !tabArg.isEmpty, + UUID(uuidString: tabArg) == nil, + Int(tabArg) == nil { + return "ERROR: Tab not found" + } + + if let scope = Self.explicitSocketScope(options: options) { + DispatchQueue.main.async { [weak self] in + guard let self, + let tab = self.tabForSidebarMutation(id: scope.workspaceId) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard validSurfaceIds.contains(scope.panelId) else { return } + mutation(tab, scope.panelId) + } + return "OK" + } + + DispatchQueue.main.async { [weak self] in + guard let self, + let tab = self.resolveTabForReport(args) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard let surfaceId = surfaceIdFromOptions ?? tab.focusedPanelId else { return } + guard validSurfaceIds.contains(surfaceId) else { return } + mutation(tab, surfaceId) + } + return "OK" + } + private func upsertSidebarMetadata(_ args: String, missingError: String) -> String { guard tabManager != nil else { return "ERROR: TabManager not available" } let parsed = parseOptionsNoStop(args) @@ -13436,6 +13526,14 @@ class TerminalController { return tabResolution.error ?? "ERROR: No tab selected" } + let pidValue: pid_t? = { + if let rawPid = normalizedOptionValue(parsed.options["pid"]), + let p = Int32(rawPid), p > 0 { + return p + } + return nil + }() + DispatchQueue.main.async { [weak self] in guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } guard Self.shouldReplaceStatusEntry( @@ -13448,6 +13546,10 @@ class TerminalController { priority: priority, format: format ) else { + // Still update PID tracking even if the status display hasn't changed. + if let pidValue { + tab.agentPIDs[key] = pidValue + } return } tab.statusEntries[key] = SidebarStatusEntry( @@ -13460,6 +13562,9 @@ class TerminalController { format: format, timestamp: Date() ) + if let pidValue { + tab.agentPIDs[key] = pidValue + } } return "OK" } @@ -13479,10 +13584,50 @@ class TerminalController { if tab.statusEntries.removeValue(forKey: key) == nil { result = "OK (key not found)" } + tab.agentPIDs.removeValue(forKey: key) } return result } + /// Register an agent PID for stale-session detection without setting a visible status entry. + /// Usage: set_agent_pid [--tab=] + private func setAgentPID(_ args: String) -> String { + let parsed = parseOptions(args) + guard parsed.positional.count >= 2, + let pid = Int32(parsed.positional[1]), pid > 0 else { + return "ERROR: Usage: set_agent_pid [--tab=]" + } + let key = parsed.positional[0] + 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 } + tab.agentPIDs[key] = pid + } + return "OK" + } + + /// Unregister an agent PID. Usage: clear_agent_pid [--tab=] + private func clearAgentPID(_ args: String) -> String { + let parsed = parseOptions(args) + guard let key = parsed.positional.first else { + return "ERROR: Usage: clear_agent_pid [--tab=]" + } + // Resolve tab ID synchronously before dispatching to avoid + // racing against selection changes on the main queue. + 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 } + tab.agentPIDs.removeValue(forKey: key) + } + return "OK" + } + private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String { var line = "\(entry.key)=\(entry.value)" if let icon = entry.icon { line += " icon=\(icon)" } @@ -13863,40 +14008,13 @@ class TerminalController { } 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 - } - + // Shell integration provides explicit workspace/panel UUIDs for browser metadata. + // Keep this telemetry path off-main so SwiftUI render passes can't deadlock the socket handler. + return schedulePanelMetadataMutation( + args: args, + options: parsed.options, + missingPanelUsage: "report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + ) { tab, surfaceId in guard Self.shouldReplacePullRequest( current: tab.panelPullRequests[surfaceId], number: number, @@ -13915,48 +14033,17 @@ class TerminalController { 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 - } - + return schedulePanelMetadataMutation( + args: args, + options: parsed.options, + missingPanelUsage: "clear_pr [--tab=X] [--panel=Y]" + ) { tab, surfaceId in tab.clearPanelPullRequest(panelId: surfaceId) } - return result } private func reportPorts(_ args: String) -> String { diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 94fae950..7cc9beb9 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -27,10 +27,10 @@ class UpdateController { } init() { - // Default to manual update checks. This also prevents Sparkle from prompting at startup. + // cmux checks for updates in the background, but keeps automatic download and + // profile submission disabled so all install intent stays user-driven. let defaults = UserDefaults.standard defaults.register(defaults: [ - "SUEnableAutomaticChecks": false, "SUSendProfileInfo": false, "SUAutomaticallyUpdate": false, ]) @@ -59,8 +59,8 @@ class UpdateController { guard !didStartUpdater else { return } ensureSparkleInstallationCache() #if DEBUG - // UI tests need to exercise Sparkle's permission request deterministically. - // Clearing these defaults causes Sparkle to re-request permission on next start. + // Keep the permission-related defaults resettable for UI tests even though the + // delegate now suppresses Sparkle's permission UI entirely. if ProcessInfo.processInfo.environment["CMUX_UI_TEST_RESET_SPARKLE_PERMISSION"] == "1" { let defaults = UserDefaults.standard defaults.removeObject(forKey: "SUEnableAutomaticChecks") @@ -71,13 +71,9 @@ class UpdateController { } #endif do { - // cmux never enables automatic update checks; we rely on the in-app update pill. - // Sparkle reads these from defaults, but set them explicitly before starting. - let defaults = UserDefaults.standard - defaults.set(false, forKey: "SUEnableAutomaticChecks") - defaults.set(false, forKey: "SUSendProfileInfo") - defaults.set(false, forKey: "SUAutomaticallyUpdate") - + updater.automaticallyChecksForUpdates = true + updater.automaticallyDownloadsUpdates = false + updater.sendsSystemProfile = false try updater.start() didStartUpdater = true } catch { @@ -201,7 +197,7 @@ class UpdateController { /// Validate the check for updates menu item. func validateMenuItem(_ item: NSMenuItem) -> Bool { if item.action == #selector(checkForUpdates) { - // Always allow user-initiated checks; we start Sparkle lazily on first use. + // Always allow user-initiated checks; Sparkle can safely surface current progress. return true } return true diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index dfcd457c..b3adfc15 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -13,6 +13,10 @@ enum UpdateFeedResolver { } extension UpdateDriver: SPUUpdaterDelegate { + func updaterShouldPromptForPermissionToCheck(forUpdates _: SPUUpdater) -> Bool { + false + } + func feedURLString(for updater: SPUUpdater) -> String? { #if DEBUG let env = ProcessInfo.processInfo.environment @@ -35,6 +39,7 @@ extension UpdateDriver: SPUUpdaterDelegate { /// Called when an update is scheduled to install silently, /// which occurs when automatic download is enabled. func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool { + viewModel.clearDetectedUpdate() viewModel.state = .installing(.init( isAutoUpdate: true, retryTerminatingApplication: immediateInstallHandler, @@ -56,6 +61,7 @@ extension UpdateDriver: SPUUpdaterDelegate { } func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + viewModel.recordDetectedUpdate(item) let version = item.displayVersionString let fileURL = item.fileURL?.absoluteString ?? "" if fileURL.isEmpty { @@ -66,6 +72,7 @@ extension UpdateDriver: SPUUpdaterDelegate { } func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { + viewModel.clearDetectedUpdate() let nsError = error as NSError let reasonValue = (nsError.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.intValue let reason = reasonValue.map { SPUNoUpdateFoundReason(rawValue: OSStatus($0)) } ?? nil @@ -80,13 +87,18 @@ extension UpdateDriver: SPUUpdaterDelegate { } } - @MainActor + func updater(_ updater: SPUUpdater, userDidMake _: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state _: SPUUserUpdateState) { + viewModel.clearDetectedUpdate() + } + func updaterWillRelaunchApplication(_ updater: SPUUpdater) { - AppDelegate.shared?.persistSessionForUpdateRelaunch() - TerminalController.shared.stop() - NSApp.invalidateRestorableState() - for window in NSApp.windows { - window.invalidateRestorableState() + Task { @MainActor in + AppDelegate.shared?.persistSessionForUpdateRelaunch() + TerminalController.shared.stop() + NSApp.invalidateRestorableState() + for window in NSApp.windows { + window.invalidateRestorableState() + } } } } diff --git a/Sources/Update/UpdateTestSupport.swift b/Sources/Update/UpdateTestSupport.swift index 2809b434..77535482 100644 --- a/Sources/Update/UpdateTestSupport.swift +++ b/Sources/Update/UpdateTestSupport.swift @@ -6,6 +6,14 @@ enum UpdateTestSupport { static func applyIfNeeded(to viewModel: UpdateViewModel) { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_MODE"] == "1" else { return } + + if let detectedVersion = env["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"], + !detectedVersion.isEmpty { + DispatchQueue.main.async { + viewModel.detectedUpdateVersion = UpdateViewModel.normalizedDetectedUpdateVersion(from: detectedVersion) + } + } + guard let state = env["CMUX_UI_TEST_UPDATE_STATE"] else { return } DispatchQueue.main.async { diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index 4bdb9ad2..7aa524d2 100644 --- a/Sources/Update/UpdateViewModel.swift +++ b/Sources/Update/UpdateViewModel.swift @@ -6,6 +6,7 @@ import Sparkle class UpdateViewModel: ObservableObject { @Published var state: UpdateState = .idle @Published var overrideState: UpdateState? + @Published var detectedUpdateVersion: String? #if DEBUG @Published var debugOverrideText: String? #endif @@ -14,6 +15,14 @@ class UpdateViewModel: ObservableObject { overrideState ?? state } + func recordDetectedUpdate(_ item: SUAppcastItem) { + detectedUpdateVersion = Self.normalizedDetectedUpdateVersion(from: item.displayVersionString) + } + + func clearDetectedUpdate() { + detectedUpdateVersion = nil + } + var text: String { #if DEBUG if let debugOverrideText { return debugOverrideText } @@ -334,6 +343,11 @@ class UpdateViewModel: ObservableObject { return nil } } + + static func normalizedDetectedUpdateVersion(from version: String) -> String? { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } enum UpdateState: Equatable { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ddd96b97..40971d17 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -240,20 +240,11 @@ extension Workspace { setCustomColor(snapshot.customColor) isPinned = snapshot.isPinned - statusEntries = Dictionary( - uniqueKeysWithValues: snapshot.statusEntries.map { entry in - ( - entry.key, - SidebarStatusEntry( - key: entry.key, - value: entry.value, - icon: entry.icon, - color: entry.color, - timestamp: Date(timeIntervalSince1970: entry.timestamp) - ) - ) - } - ) + // Status entries and agent PIDs are ephemeral runtime state tied to running + // processes (e.g. claude_code "Running"). Don't restore them across app + // restarts because the processes that set them are gone. + statusEntries.removeAll() + agentPIDs.removeAll() logEntries = snapshot.logEntries.map { entry in SidebarLogEntry( message: entry.message, @@ -382,6 +373,7 @@ extension Workspace { let historySnapshot = browserPanel.sessionNavigationHistorySnapshot() browserSnapshot = SessionBrowserPanelSnapshot( urlString: browserPanel.preferredURLStringForOmnibar(), + profileID: browserPanel.profileID, shouldRenderWebView: browserPanel.shouldRenderWebView, pageZoom: Double(browserPanel.currentPageZoomFactor()), developerToolsVisible: browserPanel.isDeveloperToolsVisible(), @@ -567,7 +559,8 @@ extension Workspace { guard let browserPanel = newBrowserSurface( inPane: paneId, url: initialURL, - focus: false + focus: false, + preferredProfileID: snapshot.browser?.profileID ) else { return nil } @@ -4517,6 +4510,7 @@ enum SidebarBranchOrdering { struct ClosedBrowserPanelRestoreSnapshot { let workspaceId: UUID let url: URL? + let profileID: UUID? let originalPaneId: UUID let originalTabIndex: Int let fallbackSplitOrientation: SplitOrientation? @@ -4534,6 +4528,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var isPinned: Bool = false @Published var customColor: String? // hex string, e.g. "#C0392B" @Published var currentDirectory: String + private(set) var preferredBrowserProfileID: UUID? /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) var portOrdinal: Int = 0 @@ -4641,6 +4636,9 @@ final class Workspace: Identifiable, ObservableObject { return formatter }() private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:] + /// PIDs associated with agent status entries (e.g. claude_code), keyed by status key. + /// Used for stale-session detection: if the PID is dead, the status entry is cleared. + var agentPIDs: [String: pid_t] = [:] private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] private static func isProxyOnlyRemoteError(_ detail: String) -> Bool { @@ -5014,6 +5012,35 @@ final class Workspace: Identifiable, ObservableObject { ) } panelSubscriptions[browserPanel.id] = subscription + setPreferredBrowserProfileID(browserPanel.profileID) + } + + func setPreferredBrowserProfileID(_ profileID: UUID?) { + guard let profileID else { + preferredBrowserProfileID = nil + return + } + guard BrowserProfileStore.shared.profileDefinition(id: profileID) != nil else { return } + preferredBrowserProfileID = profileID + } + + private func resolvedNewBrowserProfileID( + preferredProfileID: UUID? = nil, + sourcePanelId: UUID? = nil + ) -> UUID { + if let preferredProfileID, + BrowserProfileStore.shared.profileDefinition(id: preferredProfileID) != nil { + return preferredProfileID + } + if let sourcePanelId, + let sourceBrowserPanel = browserPanel(for: sourcePanelId) { + return sourceBrowserPanel.profileID + } + if let preferredBrowserProfileID, + BrowserProfileStore.shared.profileDefinition(id: preferredBrowserProfileID) != nil { + return preferredBrowserProfileID + } + return BrowserProfileStore.shared.effectiveLastUsedProfileID } private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) { @@ -5410,6 +5437,7 @@ final class Workspace: Identifiable, ObservableObject { func resetSidebarContext(reason: String = "unspecified") { statusEntries.removeAll() + agentPIDs.removeAll() logEntries.removeAll() progress = nil gitBranch = nil @@ -6301,6 +6329,7 @@ final class Workspace: Identifiable, ObservableObject { orientation: SplitOrientation, insertFirst: Bool = false, url: URL? = nil, + preferredProfileID: UUID? = nil, focus: Bool = true ) -> BrowserPanel? { // Find the pane containing the source panel @@ -6319,6 +6348,10 @@ final class Workspace: Identifiable, ObservableObject { // Create browser panel let browserPanel = BrowserPanel( workspaceId: id, + profileID: resolvedNewBrowserProfileID( + preferredProfileID: preferredProfileID, + sourcePanelId: panelId + ), initialURL: url, proxyEndpoint: remoteProxyEndpoint, isRemoteWorkspace: isRemoteWorkspace, @@ -6326,6 +6359,7 @@ final class Workspace: Identifiable, ObservableObject { ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle + setPreferredBrowserProfileID(browserPanel.profileID) // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. let newTab = Bonsplit.Tab( @@ -6382,6 +6416,7 @@ final class Workspace: Identifiable, ObservableObject { url: URL? = nil, focus: Bool? = nil, insertAtEnd: Bool = false, + preferredProfileID: UUID? = nil, bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) @@ -6390,6 +6425,7 @@ final class Workspace: Identifiable, ObservableObject { let browserPanel = BrowserPanel( workspaceId: id, + profileID: resolvedNewBrowserProfileID(preferredProfileID: preferredProfileID), initialURL: url, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, proxyEndpoint: remoteProxyEndpoint, @@ -6398,6 +6434,7 @@ final class Workspace: Identifiable, ObservableObject { ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle + setPreferredBrowserProfileID(browserPanel.profileID) guard let newTabId = bonsplitController.createTab( title: browserPanel.displayTitle, @@ -6827,6 +6864,7 @@ final class Workspace: Identifiable, ObservableObject { pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( workspaceId: id, url: resolvedURL, + profileID: browserPanel.profileID, originalPaneId: pane.id, originalTabIndex: tabIndex, fallbackSplitOrientation: fallbackPlan?.orientation, @@ -8010,14 +8048,27 @@ final class Workspace: Identifiable, ObservableObject { private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) { let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - guard let newPanel = newBrowserSurface(inPane: paneId, url: url, focus: true) else { return } + let preferredProfileID = panelIdFromSurfaceId(anchorTabId).flatMap { browserPanel(for: $0)?.profileID } + guard let newPanel = newBrowserSurface( + inPane: paneId, + url: url, + focus: true, + preferredProfileID: preferredProfileID + ) else { return } _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) } private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) { guard let panelId = panelIdFromSurfaceId(anchorTabId), let browser = browserPanel(for: panelId) else { return } - createBrowserToRight(of: anchorTabId, inPane: paneId, url: browser.currentURL) + let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) + guard let newPanel = newBrowserSurface( + inPane: paneId, + url: browser.currentURL, + focus: true, + preferredProfileID: browser.profileID + ) else { return } + _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) } private func promptRenamePanel(tabId: TabID) { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 9e90baf8..9e76784d 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -587,6 +587,10 @@ struct cmuxApp: App { BrowserHistoryStore.shared.clearHistory() } + Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) { activeTabManager.selectNextTab() } @@ -645,7 +649,6 @@ struct cmuxApp: App { private func showAboutPanel() { AboutWindowController.shared.show() - NSApp.activate(ignoringOtherApps: true) } private func applyAppearance() { @@ -1033,8 +1036,8 @@ struct cmuxApp: App { } private func closePanelOrWindow() { - if let window = NSApp.keyWindow, - window.identifier?.rawValue == "cmux.settings" { + if let window = NSApp.keyWindow ?? NSApp.mainWindow, + cmuxWindowShouldOwnCloseShortcut(window) { window.performClose(nil) return } @@ -1061,6 +1064,26 @@ struct cmuxApp: App { } } +private let cmuxAuxiliaryWindowIdentifiers: Set = [ + "cmux.settings", + "cmux.about", + "cmux.licenses", + "cmux.browser-popup", + "cmux.settingsAboutTitlebarDebug", + "cmux.debugWindowControls", + "cmux.sidebarDebug", + "cmux.menubarDebug", + "cmux.backgroundDebug", +] + +/// Returns whether the given window should handle the standard close shortcut +/// as a standalone auxiliary window instead of routing it through workspace or +/// panel-close behavior. +func cmuxWindowShouldOwnCloseShortcut(_ window: NSWindow?) -> Bool { + guard let identifier = window?.identifier?.rawValue else { return false } + return cmuxAuxiliaryWindowIdentifiers.contains(identifier) +} + private enum SettingsAboutWindowKind: String, CaseIterable, Identifiable { case settings case about @@ -1526,6 +1549,8 @@ private enum DebugWindowConfigSnapshot { sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue)) sidebarBlurOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarBlurOpacity", fallback: 1.0))) sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000")) + sidebarTintHexLight=\(stringValue(defaults, key: "sidebarTintHexLight", fallback: "(nil)")) + sidebarTintHexDark=\(stringValue(defaults, key: "sidebarTintHexDark", fallback: "(nil)")) sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18))) sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0))) sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout)) @@ -2153,8 +2178,10 @@ private struct AboutPanelView: View { private struct SidebarDebugView: View { @AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue - @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.18 - @AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000" + @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity + @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex + @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? + @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue @@ -2308,7 +2335,9 @@ private struct SidebarDebugView: View { HStack(spacing: 12) { Button("Reset Tint") { sidebarTintOpacity = 0.62 - sidebarTintHex = "#000000" + sidebarTintHex = SidebarTintDefaults.hex + sidebarTintHexLight = nil + sidebarTintHexDark = nil } Button("Reset Blur") { sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue @@ -2389,6 +2418,8 @@ private struct SidebarDebugView: View { sidebarState=\(sidebarState) sidebarBlurOpacity=\(String(format: "%.2f", sidebarBlurOpacity)) sidebarTintHex=\(sidebarTintHex) + sidebarTintHexLight=\(sidebarTintHexLight ?? "(nil)") + sidebarTintHexDark=\(sidebarTintHexDark ?? "(nil)") sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity)) sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius)) sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout) @@ -2416,6 +2447,8 @@ private struct SidebarDebugView: View { sidebarTintOpacity = preset.tintOpacity sidebarCornerRadius = preset.cornerRadius sidebarBlurOpacity = preset.blurOpacity + sidebarTintHexLight = nil + sidebarTintHexDark = nil } } @@ -3006,6 +3039,18 @@ enum CommandPaletteRenameSelectionSettings { } } +enum CommandPaletteSwitcherSearchSettings { + static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces" + static let defaultSearchAllSurfaces = false + + static func searchAllSurfacesEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: searchAllSurfacesKey) == nil { + return defaultSearchAllSurfaces + } + return defaults.bool(forKey: searchAllSurfacesKey) + } +} + enum ClaudeCodeIntegrationSettings { static let hooksEnabledKey = "claudeCodeHooksEnabled" static let defaultHooksEnabled = true @@ -3073,6 +3118,8 @@ struct SettingsView: View { @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) + private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @@ -3095,6 +3142,10 @@ struct SettingsView: View { @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true + @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex + @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? + @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? + @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity @ObservedObject private var notificationStore = TerminalNotificationStore.shared @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @@ -3104,6 +3155,7 @@ struct SettingsView: View { @State private var showOpenAccessConfirmation = false @State private var pendingOpenAccessMode: SocketControlMode? @State private var browserHistoryEntryCount: Int = 0 + @State private var detectedImportBrowsers: [InstalledBrowserCandidate] = [] @State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText @State private var socketPasswordDraft = "" @State private var socketPasswordStatusMessage: String? @@ -3169,6 +3221,30 @@ struct SettingsView: View { ) } + private var settingsSidebarTintLightBinding: Binding { + Binding( + get: { + Color(nsColor: NSColor(hex: sidebarTintHexLight ?? sidebarTintHex) ?? .black) + }, + set: { newColor in + let nsColor = NSColor(newColor) + sidebarTintHexLight = nsColor.hexString() + } + ) + } + + private var settingsSidebarTintDarkBinding: Binding { + Binding( + get: { + Color(nsColor: NSColor(hex: sidebarTintHexDark ?? sidebarTintHex) ?? .black) + }, + set: { newColor in + let nsColor = NSColor(newColor) + sidebarTintHexDark = nsColor.hexString() + } + ) + } + private var hasSocketPasswordConfigured: Bool { SocketControlPasswordStore.hasConfiguredPassword() } @@ -3184,6 +3260,10 @@ struct SettingsView: View { } } + private var browserImportSubtitle: String { + InstalledBrowserDetector.summaryText(for: detectedImportBrowsers) + } + private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } @@ -3696,6 +3776,23 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), + subtitle: commandPaletteSearchAllSurfaces + ? String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOn", defaultValue: "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces.") + : String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOff", defaultValue: "Cmd+P matches workspace rows only.") + ) { + Toggle("", isOn: $commandPaletteSearchAllSurfaces) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("CommandPaletteSearchAllSurfacesToggle") + .accessibilityLabel( + String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces") + ) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), subtitle: sidebarHideAllDetails @@ -3920,6 +4017,83 @@ struct SettingsView: View { } } + SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance")) + SettingsCard { + SettingsCardRow( + String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"), + subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.") + ) { + HStack(spacing: 8) { + ColorPicker( + String(localized: "settings.sidebarAppearance.tintColorLight.picker", defaultValue: "Light tint"), + selection: settingsSidebarTintLightBinding, + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) + + Text(sidebarTintHexLight ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"), + subtitle: String(localized: "settings.sidebarAppearance.tintColorDark.subtitle", defaultValue: "Sidebar tint color when using dark appearance.") + ) { + HStack(spacing: 8) { + ColorPicker( + String(localized: "settings.sidebarAppearance.tintColorDark.picker", defaultValue: "Dark tint"), + selection: settingsSidebarTintDarkBinding, + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) + + Text(sidebarTintHexDark ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"), + subtitle: String(localized: "settings.sidebarAppearance.tintOpacity.subtitle", defaultValue: "How strongly the tint color shows over the sidebar material.") + ) { + HStack(spacing: 8) { + Slider(value: $sidebarTintOpacity, in: 0...1) + .frame(width: 140) + Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .trailing) + } + } + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"), + subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.") + ) { + Button(String(localized: "settings.sidebarAppearance.reset.button", defaultValue: "Reset")) { + sidebarTintHexLight = nil + sidebarTintHexDark = nil + sidebarTintHex = SidebarTintDefaults.hex + sidebarTintOpacity = SidebarTintDefaults.opacity + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation")) SettingsCard { SettingsPickerRow( @@ -4196,6 +4370,25 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow(String(localized: "settings.browser.import", defaultValue: "Import From Browser"), subtitle: browserImportSubtitle) { + HStack(spacing: 8) { + Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { + BrowserDataImportCoordinator.shared.presentImportDialog() + refreshDetectedImportBrowsers() + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) { + refreshDetectedImportBrowsers() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + SettingsCardDivider() + SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) { Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) { showClearBrowserHistoryConfirmation = true @@ -4338,6 +4531,7 @@ struct SettingsView: View { browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist + refreshDetectedImportBrowsers() reloadWorkspaceTabColorSettings() refreshNotificationCustomSoundStatus() } @@ -4467,6 +4661,7 @@ struct SettingsView: View { showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces ShortcutHintDebugSettings.resetVisibilityDefaults() alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @@ -4484,11 +4679,16 @@ struct SettingsView: View { sidebarShowLog = true sidebarShowProgress = true sidebarShowMetadata = true + sidebarTintHex = SidebarTintDefaults.hex + sidebarTintHexLight = nil + sidebarTintHexDark = nil + sidebarTintOpacity = SidebarTintDefaults.opacity showOpenAccessConfirmation = false pendingOpenAccessMode = nil socketPasswordDraft = "" socketPasswordStatusMessage = nil socketPasswordStatusIsError = false + refreshDetectedImportBrowsers() KeyboardShortcutSettings.resetAll() WorkspaceTabColorSettings.reset() reloadWorkspaceTabColorSettings() @@ -4534,6 +4734,10 @@ struct SettingsView: View { private func saveBrowserInsecureHTTPAllowlist() { browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft } + + private func refreshDetectedImportBrowsers() { + detectedImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers() + } } private struct SettingsTopOffsetPreferenceKey: PreferenceKey { diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 26c25bf2..820cdb0b 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -571,6 +571,65 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) } + func testCmdWClosesAuxiliaryWindowInsteadOfMainTerminalPanel() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + XCTAssertNotNil(window(withId: windowId), "Expected test window") + + guard let manager = appDelegate.tabManagerFor(windowId: windowId) else { + XCTFail("Expected test manager") + return + } + + let mainWorkspaceCount = manager.tabs.count + let auxiliaryWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + auxiliaryWindow.isReleasedWhenClosed = false + auxiliaryWindow.identifier = NSUserInterfaceItemIdentifier("cmux.about") + auxiliaryWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + defer { + if auxiliaryWindow.isVisible { + auxiliaryWindow.performClose(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } + } + + guard let event = makeKeyDownEvent( + key: "w", + modifiers: [.command], + keyCode: 13, + windowNumber: auxiliaryWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+W event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + throw XCTSkip("debugHandleCustomShortcut is only available in DEBUG builds") +#endif + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertFalse(auxiliaryWindow.isVisible, "Cmd+W should close the auxiliary window") + XCTAssertNotNil(self.window(withId: windowId), "Cmd+W in auxiliary window should not close the main window") + XCTAssertEqual(manager.tabs.count, mainWorkspaceCount, "Cmd+W in auxiliary window should not close a terminal panel") + XCTAssertNotEqual(NSApp.keyWindow?.identifier?.rawValue, "cmux.about", "Closed auxiliary window should not remain key") + } + func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -955,6 +1014,63 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { #endif } + func testCmdShiftPRequestsCommandPaletteCommands() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let paletteExpectation = expectation(description: "Expected command palette commands request for Cmd+Shift+P") + var observedPaletteWindow: NSWindow? + let paletteToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRequested, + object: nil, + queue: nil + ) { notification in + observedPaletteWindow = notification.object as? NSWindow + paletteExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(paletteToken) } + + let switcherExpectation = expectation(description: "Cmd+Shift+P should not request command palette switcher") + switcherExpectation.isInverted = true + let switcherToken = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(switcherToken) } + + guard let event = makeKeyDownEvent( + key: "P", + modifiers: [.command, .shift], + keyCode: 35, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+Shift+P event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [paletteExpectation, switcherExpectation], timeout: 1.0) + XCTAssertEqual(observedPaletteWindow?.windowNumber, window.windowNumber) + } + func testCmdPhysicalWWithDvorakCharactersDoesNotTriggerClosePanelShortcut() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -1712,6 +1828,77 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber) } + func testArrowNavigationRoutesWhileCommandPaletteOverlayIsInteractiveBeforeVisibilitySync() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId), + let contentView = window.contentView else { + XCTFail("Expected test window") + return + } + + let overlayContainer = NSView(frame: contentView.bounds) + overlayContainer.identifier = commandPaletteOverlayContainerIdentifier + overlayContainer.alphaValue = 1 + overlayContainer.isHidden = false + contentView.addSubview(overlayContainer) + + let fieldEditor = CommandPaletteMarkedTextFieldEditor(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) + fieldEditor.isFieldEditor = true + overlayContainer.addSubview(fieldEditor) + XCTAssertTrue(window.makeFirstResponder(fieldEditor)) + + appDelegate.setCommandPaletteVisible(false, for: window) + defer { + overlayContainer.removeFromSuperview() + fieldEditor.removeFromSuperview() + } + + let moveExpectation = expectation( + description: "Expected command palette move-selection notification while overlay is interactive" + ) + var observedDelta: Int? + var observedWindow: NSWindow? + let moveToken = NotificationCenter.default.addObserver( + forName: .commandPaletteMoveSelection, + object: nil, + queue: nil + ) { notification in + observedWindow = notification.object as? NSWindow + observedDelta = notification.userInfo?["delta"] as? Int + moveExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(moveToken) } + + guard let downArrowEvent = makeKeyDownEvent( + key: String(UnicodeScalar(NSDownArrowFunctionKey)!), + modifiers: [], + keyCode: 125, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Down Arrow event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: downArrowEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [moveExpectation], timeout: 1.0) + XCTAssertEqual(observedWindow?.windowNumber, window.windowNumber) + XCTAssertEqual(observedDelta, 1) + } + func testEscapeDismissesCommandPaletteWhenVisibilityStateStaysStalePastInitialPendingWindow() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift new file mode 100644 index 00000000..1f6c662c --- /dev/null +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -0,0 +1,232 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class BrowserImportMappingTests: XCTestCase { + @MainActor + func testDefaultExecutionPlanUsesSeparateModeForMultipleSourceProfiles() { + let defaultProfile = BrowserProfileDefinition( + id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!, + displayName: "Default", + createdAt: .distantPast, + isBuiltInDefault: true + ) + let sourceProfiles = [ + makeSourceProfile(displayName: "You", path: "/tmp/browser-import-you", isDefault: true), + makeSourceProfile(displayName: "austin", path: "/tmp/browser-import-austin", isDefault: false), + ] + + let plan = BrowserImportPlanResolver.defaultPlan( + selectedSourceProfiles: sourceProfiles, + destinationProfiles: [defaultProfile], + preferredSingleDestinationProfileID: defaultProfile.id + ) + + XCTAssertEqual(plan.mode, .separateProfiles) + XCTAssertEqual(plan.entries.count, 2) + XCTAssertEqual(plan.entries.map { $0.sourceProfiles.map(\.displayName) }, [["You"], ["austin"]]) + } + + @MainActor + func testDefaultExecutionPlanUsesSingleDestinationForSingleSourceProfile() { + let defaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")! + let sourceProfile = makeSourceProfile( + displayName: "You", + path: "/tmp/browser-import-single", + isDefault: true + ) + + let plan = BrowserImportPlanResolver.defaultPlan( + selectedSourceProfiles: [sourceProfile], + destinationProfiles: [], + preferredSingleDestinationProfileID: defaultProfileID + ) + + XCTAssertEqual(plan.mode, .singleDestination) + XCTAssertEqual(plan.entries.count, 1) + XCTAssertEqual(plan.entries[0].sourceProfiles.map(\.displayName), ["You"]) + } + + @MainActor + func testSeparatePlanReusesExistingSameNamedDestinationProfiles() { + let workID = UUID() + let destinationProfiles = [ + BrowserProfileDefinition( + id: workID, + displayName: "You", + createdAt: .distantPast, + isBuiltInDefault: false + ) + ] + let sourceProfiles = [ + makeSourceProfile(displayName: " you ", path: "/tmp/browser-import-match", isDefault: true) + ] + + let plan = BrowserImportPlanResolver.separateProfilesPlan( + selectedSourceProfiles: sourceProfiles, + destinationProfiles: destinationProfiles + ) + + XCTAssertEqual(plan.entries.count, 1) + XCTAssertEqual(plan.entries[0].destination, .existing(workID)) + } + + @MainActor + func testSeparatePlanUsesStableCreateNamesWhenTwoSourceProfilesShareDisplayName() { + let sourceProfiles = [ + makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-1", isDefault: true), + makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-2", isDefault: false), + ] + + let plan = BrowserImportPlanResolver.separateProfilesPlan( + selectedSourceProfiles: sourceProfiles, + destinationProfiles: [] + ) + + XCTAssertEqual(plan.entries.count, 2) + XCTAssertEqual(plan.entries[0].destination, .createNamed("Work")) + XCTAssertEqual(plan.entries[1].destination, .createNamed("Work (2)")) + } + + func testStep3PresentationShowsPerProfileRowsWhenPlanUsesSeparateMode() { + let presentation = BrowserImportStep3Presentation( + plan: BrowserImportExecutionPlan( + mode: .separateProfiles, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: [ + makeSourceProfile( + displayName: "You", + path: "/tmp/browser-import-presentation-separate", + isDefault: true + ) + ], + destination: .createNamed("You") + ) + ] + ) + ) + + XCTAssertTrue(presentation.showsSeparateRows) + XCTAssertFalse(presentation.showsSingleDestinationPicker) + } + + func testStep3PresentationShowsSingleDestinationPickerWhenPlanUsesMergeMode() { + let presentation = BrowserImportStep3Presentation( + plan: BrowserImportExecutionPlan( + mode: .mergeIntoOne, + entries: [] + ) + ) + + XCTAssertFalse(presentation.showsSeparateRows) + XCTAssertTrue(presentation.showsSingleDestinationPicker) + } + + @MainActor + func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws { + let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = BrowserProfileStore(defaults: defaults) + let plan = BrowserImportExecutionPlan( + mode: .separateProfiles, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: [ + makeSourceProfile( + displayName: "You", + path: "/tmp/browser-import-realize-create", + isDefault: true + ) + ], + destination: .createNamed("You") + ) + ] + ) + + let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store) + + XCTAssertEqual(realized.createdProfiles.map(\.displayName), ["You"]) + XCTAssertEqual(store.profiles.map(\.displayName), ["Default", "You"]) + } + + @MainActor + func testRealizePlanReusesExistingProfileInsteadOfCreatingDuplicate() throws { + let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = BrowserProfileStore(defaults: defaults) + let existing = try XCTUnwrap(store.createProfile(named: "You")) + let plan = BrowserImportExecutionPlan( + mode: .separateProfiles, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: [ + makeSourceProfile( + displayName: "You", + path: "/tmp/browser-import-realize-existing", + isDefault: true + ) + ], + destination: .existing(existing.id) + ) + ] + ) + + let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store) + + XCTAssertTrue(realized.createdProfiles.isEmpty) + XCTAssertEqual(realized.entries[0].destinationProfileID, existing.id) + } + + func testAggregateOutcomeIncludesOneMappingLinePerDestination() { + let outcome = BrowserImportOutcome( + browserName: "Helium", + scope: .cookiesAndHistory, + domainFilters: [], + createdDestinationProfileNames: ["You", "austin"], + entries: [ + BrowserImportOutcomeEntry( + sourceProfileNames: ["You"], + destinationProfileName: "You", + importedCookies: 10, + skippedCookies: 0, + importedHistoryEntries: 20, + warnings: [] + ), + BrowserImportOutcomeEntry( + sourceProfileNames: ["austin"], + destinationProfileName: "austin", + importedCookies: 5, + skippedCookies: 1, + importedHistoryEntries: 9, + warnings: [] + ), + ], + warnings: [] + ) + + let lines = BrowserImportOutcomeFormatter.lines(for: outcome) + + XCTAssertTrue(lines.contains("You -> You")) + XCTAssertTrue(lines.contains("austin -> austin")) + XCTAssertTrue(lines.contains("Created cmux profiles: You, austin")) + } + + private func makeSourceProfile(displayName: String, path: String, isDefault: Bool) -> InstalledBrowserProfile { + InstalledBrowserProfile( + displayName: displayName, + rootURL: URL(fileURLWithPath: path, isDirectory: true), + isDefault: isDefault + ) + } +} diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 473b11e7..e67e12ae 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -232,10 +232,10 @@ final class CJKIMEMarkedTextTests: XCTestCase { // MARK: - selectedRange / validAttributesForMarkedText - func testSelectedRangeReturnsNotFound() { + func testSelectedRangeReturnsEmptyRangeWithoutSelection() { let view = GhosttyNSView(frame: .zero) let range = view.selectedRange() - XCTAssertEqual(range.location, NSNotFound) + XCTAssertEqual(range, NSRange(location: 0, length: 0)) } func testValidAttributesForMarkedTextReturnsEmpty() { @@ -694,6 +694,68 @@ final class CJKIMEFirstRectTests: XCTestCase { XCTAssertEqual(rect.width, 36, accuracy: 0.001) XCTAssertEqual(rect.height, 18, accuracy: 0.001) } + + func testFirstRectUsesZeroWidthForInsertionPointWithoutOffsettingCaretAnchor() { + let frame = NSRect(x: 0, y: 0, width: 640, height: 480) + let view = GhosttyNSView(frame: frame) + view.cellSize = CGSize(width: 9, height: 18) + view.setIMEPointForTesting(x: 80, y: 120, width: 36, height: 24) + + let window = NSWindow( + contentRect: NSRect(x: 40, y: 40, width: 640, height: 480), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let content = NSView(frame: frame) + window.contentView = content + content.addSubview(view) + view.frame = frame + + defer { + view.clearIMEPointForTesting() + window.orderOut(nil) + } + + let rect = view.firstRect(forCharacterRange: NSRange(location: 5, length: 0), actualRange: nil) + let expectedViewRect = NSRect(x: 80, y: frame.height - 120, width: 0, height: 24) + let expectedScreenRect = window.convertToScreen(view.convert(expectedViewRect, to: nil)) + + XCTAssertEqual(rect.origin.x, expectedScreenRect.origin.x, accuracy: 0.001) + XCTAssertEqual(rect.origin.y, expectedScreenRect.origin.y, accuracy: 0.001) + XCTAssertEqual(rect.width, 0, accuracy: 0.001) + XCTAssertEqual(rect.height, 24, accuracy: 0.001) + } + + func testDocumentVisibleRectUsesScreenCoordinates() { + guard #available(macOS 14.0, *) else { return } + + let frame = NSRect(x: 0, y: 0, width: 640, height: 480) + let view = GhosttyNSView(frame: frame) + + let window = NSWindow( + contentRect: NSRect(x: 40, y: 40, width: 640, height: 480), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let content = NSView(frame: frame) + window.contentView = content + content.addSubview(view) + view.frame = frame + + defer { + window.orderOut(nil) + } + + let expected = window.convertToScreen(view.convert(view.visibleRect, to: nil)) + let rect = view.documentVisibleRect + + XCTAssertEqual(rect.origin.x, expected.origin.x, accuracy: 0.001) + XCTAssertEqual(rect.origin.y, expected.origin.y, accuracy: 0.001) + XCTAssertEqual(rect.width, expected.width, accuracy: 0.001) + XCTAssertEqual(rect.height, expected.height, accuracy: 0.001) + } } // MARK: - Key text accumulator during CJK IME composition diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index b17ab943..875ee6a6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1197,6 +1197,55 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase { XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1) XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId })) } + + func testApplicationOpenURLsAddsWorkspaceForDroppedFolderURL() throws { + _ = NSApplication.shared + let app = AppDelegate() + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { window.orderOut(nil) } + + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + window.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: window) + + let defaults = UserDefaults.standard + let previousWelcomeShown = defaults.object(forKey: WelcomeSettings.shownKey) + defaults.set(true, forKey: WelcomeSettings.shownKey) + defer { + if let previousWelcomeShown { + defaults.set(previousWelcomeShown, forKey: WelcomeSettings.shownKey) + } else { + defaults.removeObject(forKey: WelcomeSettings.shownKey) + } + } + + let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let droppedDirectory = rootDirectory.appendingPathComponent("project", isDirectory: true) + try FileManager.default.createDirectory(at: droppedDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let existingWorkspaceIds = Set(manager.tabs.map(\.id)) + + app.application( + NSApplication.shared, + open: [URL(fileURLWithPath: droppedDirectory.path)] + ) + + let createdWorkspace = manager.tabs.first { !existingWorkspaceIds.contains($0.id) } + XCTAssertNotNil(createdWorkspace) + XCTAssertEqual(createdWorkspace?.currentDirectory, droppedDirectory.path) + } } @MainActor @@ -2617,6 +2666,113 @@ final class BrowserNavigationNewTabDecisionTests: XCTestCase { } } +final class BrowserPopupDecisionTests: XCTestCase { + func testLinkActivatedPlainLeftClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationPlainLeftClickCreatesPopup() { + XCTAssertTrue( + browserNavigationShouldCreatePopup( + navigationType: .other, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationMiddleClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .other, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testLinkActivatedCmdClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .linkActivated, + modifierFlags: [.command], + buttonNumber: 0 + ) + ) + } +} + +final class BrowserNilTargetFallbackDecisionTests: XCTestCase { + func testOtherNavigationDoesNotFallbackToNewTab() { + XCTAssertFalse( + browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: .other + ) + ) + } + + func testLinkActivatedNavigationFallsBackToNewTab() { + XCTAssertTrue( + browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: .linkActivated + ) + ) + } +} + +final class BrowserPopupContentRectTests: XCTestCase { + func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() { + let rect = browserPopupContentRect( + requestedWidth: 400, + requestedHeight: 300, + requestedX: 150, + requestedTopY: 120, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01) + XCTAssertEqual(rect.width, 400, accuracy: 0.01) + XCTAssertEqual(rect.height, 300, accuracy: 0.01) + } + + func testExplicitCoordinatesClampToVisibleFrame() { + let rect = browserPopupContentRect( + requestedWidth: 1400, + requestedHeight: 1200, + requestedX: 900, + requestedTopY: -25, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01) + XCTAssertEqual(rect.width, 1000, accuracy: 0.01) + XCTAssertEqual(rect.height, 800, accuracy: 0.01) + } + + func testMissingCoordinatesCentersPopup() { + let rect = browserPopupContentRect( + requestedWidth: 300, + requestedHeight: 200, + requestedX: nil, + requestedTopY: nil, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01) + XCTAssertEqual(rect.width, 300, accuracy: 0.01) + XCTAssertEqual(rect.height, 200, accuracy: 0.01) + } +} + @MainActor final class BrowserJavaScriptDialogDelegateTests: XCTestCase { func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() { @@ -5125,6 +5281,32 @@ final class WorkspaceReorderTests: XCTestCase { let manager = TabManager() XCTAssertFalse(manager.reorderWorkspace(tabId: UUID(), toIndex: 0)) } + + @MainActor + func testReorderWorkspaceKeepsUnpinnedWorkspaceBelowPinnedSegment() { + let manager = TabManager() + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let unpinned = manager.addWorkspace() + + XCTAssertTrue(manager.reorderWorkspace(tabId: unpinned.id, toIndex: 0)) + XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, secondPinned.id, unpinned.id]) + } + + @MainActor + func testReorderWorkspaceKeepsPinnedWorkspaceInsidePinnedSegment() { + let manager = TabManager() + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let unpinned = manager.addWorkspace() + + XCTAssertTrue(manager.reorderWorkspace(tabId: firstPinned.id, toIndex: 999)) + XCTAssertEqual(manager.tabs.map(\.id), [secondPinned.id, firstPinned.id, unpinned.id]) + } } @MainActor @@ -7152,14 +7334,16 @@ final class SidebarDropPlannerTests: XCTestCase { SidebarDropPlanner.indicator( draggedTabId: first, targetTabId: first, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ) ) XCTAssertNil( SidebarDropPlanner.indicator( draggedTabId: third, targetTabId: nil, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ) ) } @@ -7170,14 +7354,16 @@ final class SidebarDropPlannerTests: XCTestCase { SidebarDropPlanner.indicator( draggedTabId: only, targetTabId: nil, - tabIds: [only] + tabIds: [only], + pinnedTabIds: [] ) ) XCTAssertNil( SidebarDropPlanner.indicator( draggedTabId: only, targetTabId: only, - tabIds: [only] + tabIds: [only], + pinnedTabIds: [] ) ) } @@ -7191,7 +7377,8 @@ final class SidebarDropPlannerTests: XCTestCase { let indicator = SidebarDropPlanner.indicator( draggedTabId: second, targetTabId: nil, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ) XCTAssertEqual(indicator?.tabId, nil) XCTAssertEqual(indicator?.edge, .bottom) @@ -7207,7 +7394,8 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: second, targetTabId: nil, indicator: SidebarDropIndicator(tabId: nil, edge: .bottom), - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ) XCTAssertEqual(index, 2) } @@ -7222,7 +7410,8 @@ final class SidebarDropPlannerTests: XCTestCase { SidebarDropPlanner.indicator( draggedTabId: second, targetTabId: second, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ) ) } @@ -7238,6 +7427,7 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: first, targetTabId: second, tabIds: tabIds, + pinnedTabIds: [], pointerY: 2, targetHeight: 40 ) @@ -7254,6 +7444,7 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: first, targetTabId: second, tabIds: tabIds, + pinnedTabIds: [], pointerY: 38, targetHeight: 40 ) @@ -7264,7 +7455,8 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: first, targetTabId: second, indicator: indicator, - tabIds: tabIds + tabIds: tabIds, + pinnedTabIds: [] ), 1 ) @@ -7280,6 +7472,7 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: third, targetTabId: first, tabIds: tabIds, + pinnedTabIds: [], pointerY: 38, targetHeight: 40 ) @@ -7287,6 +7480,7 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: third, targetTabId: second, tabIds: tabIds, + pinnedTabIds: [], pointerY: 2, targetHeight: 40 ) @@ -7308,11 +7502,53 @@ final class SidebarDropPlannerTests: XCTestCase { draggedTabId: third, targetTabId: second, tabIds: tabIds, + pinnedTabIds: [], pointerY: 38, targetHeight: 40 ) ) } + + func testIndicatorSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { + let pinnedA = UUID() + let pinnedB = UUID() + let unpinnedA = UUID() + let unpinnedB = UUID() + let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] + let pinnedIds: Set = [pinnedA, pinnedB] + + let indicator = SidebarDropPlanner.indicator( + draggedTabId: unpinnedB, + targetTabId: pinnedA, + tabIds: tabIds, + pinnedTabIds: pinnedIds, + pointerY: 2, + targetHeight: 40 + ) + + XCTAssertEqual(indicator?.tabId, unpinnedA) + XCTAssertEqual(indicator?.edge, .top) + } + + func testTargetIndexSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { + let pinnedA = UUID() + let pinnedB = UUID() + let unpinnedA = UUID() + let unpinnedB = UUID() + let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] + let pinnedIds: Set = [pinnedA, pinnedB] + + let targetIndex = SidebarDropPlanner.targetIndex( + draggedTabId: unpinnedB, + targetTabId: pinnedA, + indicator: SidebarDropIndicator(tabId: pinnedA, edge: .top), + tabIds: tabIds, + pinnedTabIds: pinnedIds + ) + + XCTAssertEqual(targetIndex, 2) + } + } final class SidebarDragAutoScrollPlannerTests: XCTestCase { @@ -12055,6 +12291,18 @@ final class GhosttySurfaceOverlayTests: XCTestCase { return nil } + private func firstResponderOwnsTextField(_ firstResponder: NSResponder?, textField: NSTextField) -> Bool { + if firstResponder === textField { + return true + } + if let editor = firstResponder as? NSTextView, + editor.isFieldEditor, + editor.delegate as? NSTextField === textField { + return true + } + return false + } + func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), @@ -12187,12 +12435,105 @@ final class GhosttySurfaceOverlayTests: XCTestCase { let searchState = TerminalSurface.SearchState(needle: "example") hostedView.setSearchOverlay(searchState: searchState) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertTrue(hostedView.debugHasSearchOverlay()) hostedView.setSearchOverlay(searchState: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertFalse(hostedView.debugHasSearchOverlay()) } + func testRapidSearchOverlayToggleDoesNotLeaveStaleOverlayMounted() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "example")) + hostedView.setSearchOverlay(searchState: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + hostedView.debugHasSearchOverlay(), + "A stale deferred mount must not resurrect the find overlay after it closes" + ) + } + + func testSearchOverlayFocusesSearchFieldAfterDeferredAttach() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + + let searchState = TerminalSurface.SearchState(needle: "") + surface.searchState = searchState + hostedView.setSearchOverlay(searchState: searchState) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let searchField = findEditableTextField(in: hostedView) else { + XCTFail("Expected mounted find text field") + return + } + + XCTAssertTrue( + firstResponderOwnsTextField(window.firstResponder, textField: searchField), + "Deferred search overlay attach should still move focus into the find field" + ) + } + + func testStartOrFocusTerminalSearchReusesExistingSearchState() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let existingSearchState = TerminalSurface.SearchState(needle: "existing") + surface.searchState = existingSearchState + + var focusNotificationCount = 0 + XCTAssertTrue( + startOrFocusTerminalSearch(surface) { _ in + focusNotificationCount += 1 + } + ) + + XCTAssertTrue(surface.searchState === existingSearchState) + XCTAssertEqual( + focusNotificationCount, + 1, + "Re-triggering terminal Find should refocus the existing overlay without recreating state" + ) + } + func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() { _ = NSApplication.shared @@ -12428,6 +12769,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) let hostedView = surface.hostedView hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split")) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertTrue(hostedView.debugHasSearchOverlay()) portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true) @@ -12466,6 +12808,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) let hostedView = surface.hostedView hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace")) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertTrue(hostedView.debugHasSearchOverlay()) portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true) diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index fd9ada43..6976ad1d 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -434,6 +434,78 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) } + func testVisibleResultsResetWhenQueryChangesCommandPaletteScope() { + XCTAssertTrue( + ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition( + oldQuery: ">", + newQuery: "", + hasVisibleResults: true + ) + ) + XCTAssertTrue( + ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition( + oldQuery: "", + newQuery: ">", + hasVisibleResults: true + ) + ) + XCTAssertFalse( + ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition( + oldQuery: ">rename", + newQuery: ">renam", + hasVisibleResults: true + ) + ) + XCTAssertFalse( + ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition( + oldQuery: ">", + newQuery: "", + hasVisibleResults: false + ) + ) + } + + func testRefreshInputsPreferObservedQueryOverStaleState() { + let inputs = ContentView.commandPaletteRefreshInputsForTests( + stateQuery: ">", + observedQuery: "", + searchAllSurfaces: true + ) + + XCTAssertEqual(inputs.scope, "switcher") + XCTAssertEqual(inputs.matchingQuery, "") + XCTAssertFalse(inputs.includesSurfaces) + } + + func testRefreshInputsIncludeSurfacesOnlyForNonEmptySwitcherQuery() { + let switcherInputs = ContentView.commandPaletteRefreshInputsForTests( + stateQuery: "", + observedQuery: " feature/search ", + searchAllSurfaces: true + ) + XCTAssertEqual(switcherInputs.scope, "switcher") + XCTAssertEqual(switcherInputs.matchingQuery, "feature/search") + XCTAssertTrue(switcherInputs.includesSurfaces) + + let commandInputs = ContentView.commandPaletteRefreshInputsForTests( + stateQuery: "", + observedQuery: ">feature/search", + searchAllSurfaces: true + ) + XCTAssertEqual(commandInputs.scope, "commands") + XCTAssertEqual(commandInputs.matchingQuery, "feature/search") + XCTAssertFalse(commandInputs.includesSurfaces) + + let workspaceOnlyInputs = ContentView.commandPaletteRefreshInputsForTests( + stateQuery: "", + observedQuery: "feature/search", + searchAllSurfaces: false + ) + XCTAssertEqual(workspaceOnlyInputs.scope, "switcher") + XCTAssertEqual(workspaceOnlyInputs.matchingQuery, "feature/search") + XCTAssertFalse(workspaceOnlyInputs.includesSurfaces) + } + func testCommandContextFingerprintTracksExactContextValues() { let base = ContentView.commandPaletteContextFingerprint( boolValues: [ @@ -490,7 +562,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase { directories: ["/Users/example/dev/cmuxterm"], branches: ["feature/search-speed"], ports: [3000] - ) + ), + surfaces: [] ) ] ) @@ -510,7 +583,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase { directories: ["/Users/example/dev/other"], branches: ["feature/search-speed"], ports: [4000] - ) + ), + surfaces: [] ) ] ) @@ -530,7 +604,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase { directories: ["/Users/example/dev/cmuxterm"], branches: ["feature/search-speed"], ports: [3000] - ) + ), + surfaces: [] ) ] ) @@ -541,6 +616,100 @@ final class CommandPaletteSearchEngineTests: XCTestCase { XCTAssertNotEqual(base, changedDisplayName) } + func testSwitcherFingerprintTracksSurfaceValuesAtSameCardinality() { + let windowID = UUID() + let workspaceID = UUID() + let surfaceID = UUID() + + let base = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: nil, + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Alpha", + metadata: CommandPaletteSwitcherSearchMetadata(), + surfaces: [ + ContentView.CommandPaletteSwitcherFingerprintSurface( + id: surfaceID, + displayName: "Terminal", + kindLabel: "Terminal", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/tmp/search-alpha"], + branches: ["feature/a"], + ports: [3000] + ) + ) + ] + ) + ] + ) + ] + ) + let changedSurfaceMetadata = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: nil, + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Alpha", + metadata: CommandPaletteSwitcherSearchMetadata(), + surfaces: [ + ContentView.CommandPaletteSwitcherFingerprintSurface( + id: surfaceID, + displayName: "Terminal", + kindLabel: "Terminal", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/tmp/search-beta"], + branches: ["feature/a"], + ports: [3000] + ) + ) + ] + ) + ] + ) + ] + ) + let changedSurfaceKind = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: nil, + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Alpha", + metadata: CommandPaletteSwitcherSearchMetadata(), + surfaces: [ + ContentView.CommandPaletteSwitcherFingerprintSurface( + id: surfaceID, + displayName: "Terminal", + kindLabel: "Browser", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/tmp/search-alpha"], + branches: ["feature/a"], + ports: [3000] + ) + ) + ] + ) + ] + ) + ] + ) + + XCTAssertNotEqual(base, changedSurfaceMetadata) + XCTAssertNotEqual(base, changedSurfaceKind) + } + func testCommandSearchBenchmarkBeatsLegacyPipeline() { let entries = makeCommandEntries(count: 900) let corpus = entries.map { entry in diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 8ed63b33..367d8d73 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -47,19 +47,6 @@ final class GhosttyConfigTests: XCTestCase { let blue: Int } - private func writeAppSupportConfig( - root: URL, - bundleIdentifier: String, - name: String = "config", - contents: String = "font-size = 14\n" - ) throws -> URL { - let directory = root.appendingPathComponent(bundleIdentifier, isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let url = directory.appendingPathComponent(name, isDirectory: false) - try contents.write(to: url, atomically: true, encoding: .utf8) - return url - } - func testResolveThemeNamePrefersLightEntryForPairedTheme() { let resolved = GhosttyConfig.resolveThemeName( from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark", @@ -324,71 +311,84 @@ final class GhosttyConfigTests: XCTestCase { ) } - func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true) - try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } + func testCmuxAppSupportConfigURLsUseReleaseConfigForDebugBundleWithoutCurrentConfig() throws { + try withTemporaryAppSupportDirectory { appSupportDirectory in + let releaseConfigURL = try writeAppSupportConfig( + appSupportDirectory: appSupportDirectory, + bundleIdentifier: "com.cmuxterm.app", + filename: "config", + contents: "font-size = 13\n" + ) - let releaseURL = try? writeAppSupportConfig( - root: root, - bundleIdentifier: "com.cmuxterm.app" - ) - - XCTAssertEqual( - GhosttyApp.cmuxAppSupportConfigURLs( - currentBundleIdentifier: "com.cmuxterm.app.debug", - appSupportDirectory: root - ), - [releaseURL].compactMap { $0 } - ) + XCTAssertEqual( + GhosttyApp.cmuxAppSupportConfigURLs( + currentBundleIdentifier: "com.cmuxterm.app.debug", + appSupportDirectory: appSupportDirectory + ), + [releaseConfigURL] + ) + } } - func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true) - try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } + func testCmuxAppSupportConfigURLsPreferCurrentBundleConfigWhenPresent() throws { + try withTemporaryAppSupportDirectory { appSupportDirectory in + _ = try writeAppSupportConfig( + appSupportDirectory: appSupportDirectory, + bundleIdentifier: "com.cmuxterm.app", + filename: "config", + contents: "font-size = 13\n" + ) + let currentConfigURL = try writeAppSupportConfig( + appSupportDirectory: appSupportDirectory, + bundleIdentifier: "com.cmuxterm.app.debug.issue-829", + filename: "config.ghostty", + contents: "font-size = 14\n" + ) - _ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app") - let debugURL = try? writeAppSupportConfig( - root: root, - bundleIdentifier: "com.cmuxterm.app.debug.issue-829", - name: "config.ghostty" - ) - - XCTAssertEqual( - GhosttyApp.cmuxAppSupportConfigURLs( - currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829", - appSupportDirectory: root - ), - [debugURL].compactMap { $0 } - ) + XCTAssertEqual( + GhosttyApp.cmuxAppSupportConfigURLs( + currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829", + appSupportDirectory: appSupportDirectory + ), + [currentConfigURL] + ) + } } - func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true) - try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } + func testCmuxAppSupportConfigURLsSkipReleaseFallbackForNonDebugBundle() throws { + try withTemporaryAppSupportDirectory { appSupportDirectory in + _ = try writeAppSupportConfig( + appSupportDirectory: appSupportDirectory, + bundleIdentifier: "com.cmuxterm.app", + filename: "config", + contents: "font-size = 13\n" + ) - _ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app") + XCTAssertTrue( + GhosttyApp.cmuxAppSupportConfigURLs( + currentBundleIdentifier: "com.example.other-app", + appSupportDirectory: appSupportDirectory + ).isEmpty + ) + } + } - XCTAssertEqual( - GhosttyApp.cmuxAppSupportConfigURLs( - currentBundleIdentifier: "com.cmuxterm.app", - appSupportDirectory: root - ).count, - 1 - ) + func testCmuxAppSupportConfigURLsIgnoreMissingOrEmptyFiles() throws { + try withTemporaryAppSupportDirectory { appSupportDirectory in + _ = try writeAppSupportConfig( + appSupportDirectory: appSupportDirectory, + bundleIdentifier: "com.cmuxterm.app", + filename: "config.ghostty", + contents: "" + ) - XCTAssertEqual( - GhosttyApp.cmuxAppSupportConfigURLs( - currentBundleIdentifier: "com.cmuxterm.app.debug", - appSupportDirectory: root.appendingPathComponent("missing", isDirectory: true) - ), - [] - ) + XCTAssertTrue( + GhosttyApp.cmuxAppSupportConfigURLs( + currentBundleIdentifier: "com.cmuxterm.app.debug", + appSupportDirectory: appSupportDirectory + ).isEmpty + ) + } } func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() { @@ -597,6 +597,33 @@ final class GhosttyConfigTests: XCTestCase { blue: Int(round(blue * 255)) ) } + + private func withTemporaryAppSupportDirectory( + _ body: (URL) throws -> Void + ) throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appendingPathComponent("cmux-app-support-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directory) } + try body(directory) + } + + private func writeAppSupportConfig( + appSupportDirectory: URL, + bundleIdentifier: String, + filename: String, + contents: String + ) throws -> URL { + let fileManager = FileManager.default + let bundleDirectory = appSupportDirectory + .appendingPathComponent(bundleIdentifier, isDirectory: true) + try fileManager.createDirectory(at: bundleDirectory, withIntermediateDirectories: true) + + let configURL = bundleDirectory.appendingPathComponent(filename, isDirectory: false) + try contents.write(to: configURL, atomically: true, encoding: .utf8) + return configURL + } } final class WorkspaceChromeThemeTests: XCTestCase { @@ -1447,6 +1474,7 @@ final class RecentlyClosedBrowserStackTests: XCTestCase { ClosedBrowserPanelRestoreSnapshot( workspaceId: UUID(), url: URL(string: "https://example.com/\(index)"), + profileID: nil, originalPaneId: UUID(), originalTabIndex: index, fallbackSplitOrientation: .horizontal, @@ -2018,6 +2046,157 @@ final class GhosttyMouseFocusTests: XCTestCase { } } +final class SidebarBackgroundConfigTests: XCTestCase { + + func testParseSidebarBackgroundSingleHex() { + var config = GhosttyConfig() + config.parse("sidebar-background = #336699") + XCTAssertEqual(config.rawSidebarBackground, "#336699") + } + + func testParseSidebarBackgroundDualMode() { + var config = GhosttyConfig() + config.parse("sidebar-background = light:#fbf3db,dark:#103c48") + XCTAssertEqual(config.rawSidebarBackground, "light:#fbf3db,dark:#103c48") + } + + func testParseSidebarTintOpacity() { + var config = GhosttyConfig() + config.parse("sidebar-tint-opacity = 0.4") + XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.4, accuracy: 0.0001) + } + + func testParseSidebarTintOpacityClampedAboveOne() { + var config = GhosttyConfig() + config.parse("sidebar-tint-opacity = 1.5") + XCTAssertEqual(config.sidebarTintOpacity ?? -1, 1.0, accuracy: 0.0001) + } + + func testParseSidebarTintOpacityClampedBelowZero() { + var config = GhosttyConfig() + config.parse("sidebar-tint-opacity = -0.3") + XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.0, accuracy: 0.0001) + } + + func testResolveSidebarBackgroundSingleHex() { + var config = GhosttyConfig() + config.rawSidebarBackground = "#336699" + config.resolveSidebarBackground(preferredColorScheme: .light) + + XCTAssertNotNil(config.sidebarBackground) + XCTAssertNil(config.sidebarBackgroundLight) + XCTAssertNil(config.sidebarBackgroundDark) + } + + func testResolveSidebarBackgroundDualModeSetsLightAndDark() { + var config = GhosttyConfig() + config.rawSidebarBackground = "light:#fbf3db,dark:#103c48" + config.resolveSidebarBackground(preferredColorScheme: .light) + + XCTAssertNotNil(config.sidebarBackgroundLight) + XCTAssertNotNil(config.sidebarBackgroundDark) + XCTAssertNotNil(config.sidebarBackground) + } + + func testResolveSidebarBackgroundNilWhenNoRaw() { + var config = GhosttyConfig() + config.resolveSidebarBackground(preferredColorScheme: .dark) + + XCTAssertNil(config.sidebarBackground) + XCTAssertNil(config.sidebarBackgroundLight) + XCTAssertNil(config.sidebarBackgroundDark) + } + + func testApplyToUserDefaultsSkipsWritesWhenNoConfig() { + let defaults = UserDefaults.standard + let testKey = "sidebarTintHex" + let original = defaults.string(forKey: testKey) + defer { restoreDefaultsValue(original, key: testKey, defaults: defaults) } + + defaults.set("#AAAAAA", forKey: testKey) + + var config = GhosttyConfig() + config.applySidebarAppearanceToUserDefaults() + + XCTAssertEqual(defaults.string(forKey: testKey), "#AAAAAA", + "Should not overwrite UserDefaults when rawSidebarBackground is nil") + } + + func testApplyToUserDefaultsWritesHexWhenConfigSet() { + let defaults = UserDefaults.standard + let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"] + let originals = keys.map { defaults.object(forKey: $0) } + defer { + for (key, original) in zip(keys, originals) { + restoreDefaultsValue(original, key: key, defaults: defaults) + } + } + + var config = GhosttyConfig() + config.rawSidebarBackground = "#336699" + config.resolveSidebarBackground(preferredColorScheme: .light) + config.applySidebarAppearanceToUserDefaults() + + XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#336699") + XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight")) + XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark")) + } + + func testApplyToUserDefaultsClearsStaleKeysOnSwitchFromDualToSingle() { + let defaults = UserDefaults.standard + let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"] + let originals = keys.map { defaults.object(forKey: $0) } + defer { + for (key, original) in zip(keys, originals) { + restoreDefaultsValue(original, key: key, defaults: defaults) + } + } + + defaults.set("#AAAAAA", forKey: "sidebarTintHexLight") + defaults.set("#BBBBBB", forKey: "sidebarTintHexDark") + + var config = GhosttyConfig() + config.rawSidebarBackground = "#222222" + config.resolveSidebarBackground(preferredColorScheme: .light) + config.applySidebarAppearanceToUserDefaults() + + XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#222222") + XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight"), + "Stale light key should be cleared") + XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark"), + "Stale dark key should be cleared") + } + + func testApplyToUserDefaultsOnlyWritesOpacityWhenExplicit() { + let defaults = UserDefaults.standard + let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", "sidebarTintOpacity"] + let originals = keys.map { defaults.object(forKey: $0) } + defer { + for (key, original) in zip(keys, originals) { + restoreDefaultsValue(original, key: key, defaults: defaults) + } + } + + defaults.set(0.18, forKey: "sidebarTintOpacity") + + var config = GhosttyConfig() + config.rawSidebarBackground = "#336699" + config.resolveSidebarBackground(preferredColorScheme: .light) + config.applySidebarAppearanceToUserDefaults() + + XCTAssertEqual(defaults.double(forKey: "sidebarTintOpacity"), 0.18, accuracy: 0.0001, + "Should not overwrite opacity when config doesn't set sidebar-tint-opacity") + } + + private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) { + if let value = value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } +} + final class ZshShellIntegrationHandoffTests: XCTestCase { func testGhosttyPromptHooksLoadWhenCmuxRequestsZshIntegration() throws { let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: true) @@ -2133,3 +2312,232 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { return output.trimmingCharacters(in: .whitespacesAndNewlines) } } + +final class BrowserInstallDetectorTests: XCTestCase { + func testDetectInstalledBrowsersUsesBundleIdAndProfileData() throws { + let home = makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + + try createFile( + at: home + .appendingPathComponent("Library/Application Support/Google/Chrome/Default/History"), + contents: Data() + ) + try createFile( + at: home + .appendingPathComponent("Library/Application Support/Firefox/Profiles/dev.default-release/cookies.sqlite"), + contents: Data() + ) + + let detected = InstalledBrowserDetector.detectInstalledBrowsers( + homeDirectoryURL: home, + bundleLookup: { bundleIdentifier in + if bundleIdentifier == "com.google.Chrome" { + return URL(fileURLWithPath: "/Applications/Google Chrome.app", isDirectory: true) + } + return nil + }, + applicationSearchDirectories: [] + ) + + guard let chrome = detected.first(where: { $0.descriptor.id == "google-chrome" }) else { + XCTFail("Expected Chrome to be detected") + return + } + guard let firefox = detected.first(where: { $0.descriptor.id == "firefox" }) else { + XCTFail("Expected Firefox to be detected from profile data") + return + } + + XCTAssertNotNil(chrome.appURL) + XCTAssertEqual(firefox.profileURLs.count, 1) + XCTAssertNil(firefox.appURL) + } + + func testDetectInstalledBrowsersReturnsEmptyWhenNoSignalsExist() throws { + let home = makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + + let detected = InstalledBrowserDetector.detectInstalledBrowsers( + homeDirectoryURL: home, + bundleLookup: { _ in nil }, + applicationSearchDirectories: [] + ) + + XCTAssertTrue(detected.isEmpty) + } + + func testUngoogledChromiumRequiresAppSignal() throws { + let home = makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + + try createFile( + at: home + .appendingPathComponent("Library/Application Support/Chromium/Default/History"), + contents: Data() + ) + + let detected = InstalledBrowserDetector.detectInstalledBrowsers( + homeDirectoryURL: home, + bundleLookup: { _ in nil }, + applicationSearchDirectories: [] + ) + + XCTAssertTrue(detected.contains(where: { $0.descriptor.id == "chromium" })) + XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" })) + } + + func testDetectInstalledBrowsersDiscoversHeliumProfilesFromChromiumLayout() throws { + let home = makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + + let heliumRoot = home.appendingPathComponent("Library/Application Support/net.imput.helium", isDirectory: true) + try createFile( + at: heliumRoot.appendingPathComponent("Default/History"), + contents: Data() + ) + try createFile( + at: heliumRoot.appendingPathComponent("Profile 1/Cookies"), + contents: Data() + ) + try createFile( + at: heliumRoot.appendingPathComponent("Local State"), + contents: Data( + """ + { + "profile": { + "info_cache": { + "Default": { + "name": "Personal" + }, + "Profile 1": { + "name": "Work" + } + } + } + } + """.utf8 + ) + ) + + let detected = InstalledBrowserDetector.detectInstalledBrowsers( + homeDirectoryURL: home, + bundleLookup: { _ in nil }, + applicationSearchDirectories: [] + ) + + guard let helium = detected.first(where: { $0.descriptor.id == "helium" }) else { + XCTFail("Expected Helium to be detected") + return + } + + XCTAssertEqual(helium.family, .chromium) + XCTAssertEqual(helium.profiles.map(\.displayName), ["Personal", "Work"]) + XCTAssertEqual( + helium.profiles.map(\.rootURL.lastPathComponent), + ["Default", "Profile 1"] + ) + } + + func testDetectInstalledBrowsersDiscoversSafariProfiles() throws { + let home = makeTemporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + + try createFile( + at: home.appendingPathComponent("Library/Safari/History.db"), + contents: Data() + ) + try createFile( + at: home.appendingPathComponent( + "Library/Safari/Profiles/Work/History.db" + ), + contents: Data() + ) + try createFile( + at: home.appendingPathComponent( + "Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel/History.db" + ), + contents: Data() + ) + + let detected = InstalledBrowserDetector.detectInstalledBrowsers( + homeDirectoryURL: home, + bundleLookup: { _ in nil }, + applicationSearchDirectories: [] + ) + + guard let safari = detected.first(where: { $0.descriptor.id == "safari" }) else { + XCTFail("Expected Safari to be detected") + return + } + + XCTAssertEqual(safari.profiles.map(\.displayName), ["Default", "Work", "Travel"]) + XCTAssertEqual( + safari.profiles.map { $0.rootURL.path(percentEncoded: false) }.sorted(), + [ + home.appendingPathComponent("Library/Safari", isDirectory: true).path(percentEncoded: false), + home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true).path(percentEncoded: false), + home.appendingPathComponent( + "Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel", + isDirectory: true + ).path(percentEncoded: false), + ].sorted() + ) + } + + private func makeTemporaryHome() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)") + } + + private func createFile(at url: URL, contents: Data) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + _ = FileManager.default.createFile(atPath: url.path, contents: contents) + } +} + +final class BrowserImportScopeTests: XCTestCase { + func testFromSelectionCookiesOnly() { + let scope = BrowserImportScope.fromSelection( + includeCookies: true, + includeHistory: false, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .cookiesOnly) + } + + func testFromSelectionHistoryOnly() { + let scope = BrowserImportScope.fromSelection( + includeCookies: false, + includeHistory: true, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .historyOnly) + } + + func testFromSelectionCookiesAndHistory() { + let scope = BrowserImportScope.fromSelection( + includeCookies: true, + includeHistory: true, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .cookiesAndHistory) + } + + func testFromSelectionEverything() { + let scope = BrowserImportScope.fromSelection( + includeCookies: false, + includeHistory: false, + includeAdditionalData: true + ) + XCTAssertEqual(scope, .everything) + } + + func testFromSelectionRejectsEmptySelection() { + let scope = BrowserImportScope.fromSelection( + includeCookies: false, + includeHistory: false, + includeAdditionalData: false + ) + XCTAssertNil(scope) + } +} diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index d9ae0472..983bed33 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -184,8 +184,10 @@ final class SessionPersistenceTests: XCTestCase { } func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws { + let profileID = UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64") let source = SessionBrowserPanelSnapshot( urlString: "https://example.com/current", + profileID: profileID, shouldRenderWebView: true, pageZoom: 1.2, developerToolsVisible: true, @@ -201,6 +203,7 @@ final class SessionPersistenceTests: XCTestCase { let data = try JSONEncoder().encode(source) let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data) XCTAssertEqual(decoded.urlString, source.urlString) + XCTAssertEqual(decoded.profileID, source.profileID) XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings) XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings) } @@ -217,6 +220,7 @@ final class SessionPersistenceTests: XCTestCase { let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json) XCTAssertEqual(decoded.urlString, "https://example.com/current") + XCTAssertNil(decoded.profileID) XCTAssertNil(decoded.backHistoryURLStrings) XCTAssertNil(decoded.forwardHistoryURLStrings) } diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift new file mode 100644 index 00000000..78a5fbb1 --- /dev/null +++ b/cmuxTests/SidebarWidthPolicyTests.swift @@ -0,0 +1,17 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class SidebarWidthPolicyTests: XCTestCase { + func testContentViewClampAllowsNarrowSidebarBelowLegacyMinimum() { + XCTAssertEqual( + ContentView.clampedSidebarWidth(184, maximumWidth: 600), + 184, + accuracy: 0.001 + ) + } +} diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift new file mode 100644 index 00000000..eca6d360 --- /dev/null +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -0,0 +1,159 @@ +import XCTest +import Foundation + +final class BrowserImportProfilesUITests: XCTestCase { + private var capturePath = "" + + override func setUp() { + super.setUp() + continueAfterFailure = false + capturePath = "/tmp/cmux-ui-test-browser-import-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: capturePath) + } + + func testMultipleSourceProfilesDefaultToSeparateDestinations() throws { + let app = launchApp() + + openImportWizard(app) + app.buttons["Next"].click() + app.buttons["Next"].click() + + XCTAssertTrue( + app.radioButtons["Keep profiles separate"].waitForExistence(timeout: 5.0), + "Expected Step 3 to show the separate-profiles default" + ) + XCTAssertTrue(app.radioButtons["Merge all into one cmux profile"].exists) + XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-you"].exists) + XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-austin"].exists) + + app.buttons["Start Import"].click() + + let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0)) + XCTAssertEqual(capture["mode"] as? String, "separateProfiles") + XCTAssertEqual(capture["scope"] as? String, "cookiesAndHistory") + + let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]]) + XCTAssertEqual(entries.count, 2) + XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You"]) + XCTAssertEqual(entries[0]["destinationKind"] as? String, "create") + XCTAssertEqual(entries[0]["destinationName"] as? String, "You") + XCTAssertEqual(entries[1]["sourceProfiles"] as? [String], ["austin"]) + XCTAssertEqual(entries[1]["destinationKind"] as? String, "create") + XCTAssertEqual(entries[1]["destinationName"] as? String, "austin") + } + + func testMergeModeCapturesSingleMergedDestination() throws { + let app = launchApp() + + openImportWizard(app) + app.buttons["Next"].click() + app.buttons["Next"].click() + + let mergeRadio = app.radioButtons["Merge all into one cmux profile"] + XCTAssertTrue(mergeRadio.waitForExistence(timeout: 5.0)) + mergeRadio.click() + + XCTAssertTrue( + app.popUpButtons["BrowserImportDestinationPopup-merge"].waitForExistence(timeout: 5.0), + "Expected merge mode to show the single destination popup" + ) + + app.buttons["Start Import"].click() + + let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0)) + XCTAssertEqual(capture["mode"] as? String, "mergeIntoOne") + + let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]]) + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You", "austin"]) + XCTAssertEqual(entries[0]["destinationKind"] as? String, "existing") + XCTAssertEqual(entries[0]["destinationName"] as? String, "Default") + } + + func testAdditionalDataSelectionCapturesEverythingScope() throws { + let app = launchApp() + + openImportWizard(app) + app.buttons["Next"].click() + app.buttons["Next"].click() + + let cookiesCheckbox = app.checkBoxes["BrowserImportCookiesCheckbox"] + XCTAssertTrue(cookiesCheckbox.waitForExistence(timeout: 5.0)) + cookiesCheckbox.click() + + let historyCheckbox = app.checkBoxes["BrowserImportHistoryCheckbox"] + XCTAssertTrue(historyCheckbox.waitForExistence(timeout: 5.0)) + historyCheckbox.click() + + let additionalDataCheckbox = app.checkBoxes["BrowserImportAdditionalDataCheckbox"] + XCTAssertTrue( + additionalDataCheckbox.waitForExistence(timeout: 5.0), + "Expected Step 3 to expose the additional data checkbox" + ) + additionalDataCheckbox.click() + + app.buttons["Start Import"].click() + + let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0)) + XCTAssertEqual(capture["scope"] as? String, "everything") + } + + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"] = #"{"browserName":"Helium","profiles":["You","austin"]}"# + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"# + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"] = capturePath + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch in the foreground for browser import UI tests" + ) + return app + } + + private func openImportWizard(_ app: XCUIApplication) { + let viewMenu = app.menuBars.menuBarItems["View"].firstMatch + XCTAssertTrue(viewMenu.waitForExistence(timeout: 5.0), "Expected View menu to exist") + viewMenu.click() + + let importItem = app.menuItems["Import From Browser…"].firstMatch + XCTAssertTrue(importItem.waitForExistence(timeout: 5.0), "Expected Import From Browser menu item to exist") + importItem.click() + + XCTAssertTrue( + app.staticTexts["Import Browser Data"].waitForExistence(timeout: 5.0), + "Expected the import wizard to open" + ) + } + + private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? { + let deadline = Date().addingTimeInterval(timeout) + let url = URL(fileURLWithPath: capturePath) + while Date() < deadline { + if let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return object + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return object + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } +} diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift index 9ba6cb4a..b27cee62 100644 --- a/cmuxUITests/SidebarHelpMenuUITests.swift +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -294,3 +294,456 @@ final class FeedbackComposerShortcutUITests: XCTestCase { ) } } + +final class CommandPaletteAllSurfacesUITests: XCTestCase { + private var socketPath = "" + private let hiddenSurfaceToken = "cmux-command-palette-hidden-surface" + private let visibleSurfaceToken = "cmux-command-palette-visible-surface" + + override func setUp() { + super.setUp() + continueAfterFailure = false + socketPath = "/tmp/cmux-ui-test-command-palette-\(UUID().uuidString).sock" + try? FileManager.default.removeItem(atPath: socketPath) + } + + override func tearDown() { + try? FileManager.default.removeItem(atPath: socketPath) + super.tearDown() + } + + func testCmdShiftPBackspaceReturnsToWorkspaceResults() throws { + let app = XCUIApplication() + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + launchAndActivate(app) + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 8.0) { + app.windows.count >= 1 + }, + "Expected the main window to be visible" + ) + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") + + let mainWindowId = try XCTUnwrap( + socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines) + ) + + openCommandPaletteCommands(app: app) + + _ = try XCTUnwrap( + waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "commands", query: "", timeout: 5.0) { snapshot in + self.commandPaletteResultRows(from: snapshot).contains { row in + let commandId = row["command_id"] as? String ?? "" + return !commandId.hasPrefix("switcher.") + } + } + ) + + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + + let switcherSnapshot = try XCTUnwrap( + waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "switcher", query: "", timeout: 5.0) { snapshot in + self.commandPaletteResultRows(from: snapshot).contains { row in + let commandId = row["command_id"] as? String ?? "" + return commandId.hasPrefix("switcher.workspace.") + } + } + ) + + XCTAssertTrue( + commandPaletteResultRows(from: switcherSnapshot).contains { row in + let commandId = row["command_id"] as? String ?? "" + return commandId.hasPrefix("switcher.workspace.") + }, + "Expected deleting the command prefix to restore workspace rows. snapshot=\(switcherSnapshot)" + ) + + let rows = commandPaletteResultRows(from: switcherSnapshot) + let firstRowCommandId = rows.first?["command_id"] as? String ?? "" + XCTAssertTrue( + firstRowCommandId.hasPrefix("switcher.workspace."), + "Expected the first restored row to be a workspace. snapshot=\(switcherSnapshot)" + ) + + let firstWorkspaceRow = try XCTUnwrap( + rows.first(where: { row in + let commandId = row["command_id"] as? String ?? "" + return commandId.hasPrefix("switcher.workspace.") + }), + "Expected a workspace row in the restored switcher results. snapshot=\(switcherSnapshot)" + ) + let workspaceTitle = try XCTUnwrap( + firstWorkspaceRow["title"] as? String, + "Expected the restored workspace row to include a title. snapshot=\(switcherSnapshot)" + ) + let workspaceLabel = app.staticTexts[workspaceTitle].firstMatch + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 2.0) { + workspaceLabel.exists && workspaceLabel.isHittable + }, + "Expected the restored workspace row to be visibly rendered. title=\(workspaceTitle) snapshot=\(switcherSnapshot)" + ) + + let staleCommandLabel = app.staticTexts["Close Other Workspaces"].firstMatch + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 2.0) { + !staleCommandLabel.exists || !staleCommandLabel.isHittable + }, + "Expected the stale command row to disappear after deleting the command prefix. snapshot=\(switcherSnapshot)" + ) + } + + func testCmdPSearchCanIncludeSurfacesFromOtherWorkspacesWhenEnabled() throws { + let app = XCUIApplication() + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SHOW_SETTINGS"] = "1" + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + launchAndActivate(app) + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 8.0) { + app.windows.count >= 2 + }, + "Expected the main window and Settings window to be visible" + ) + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") + + let mainWindowId = try XCTUnwrap(socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines)) + let secondaryWorkspaceId = try XCTUnwrap(okUUID(from: socketCommand("new_workspace"))) + let initialSurfaceId = try XCTUnwrap(waitForSurfaceIDs(minimumCount: 1, timeout: 5.0).first) + let hiddenSurfaceId = try XCTUnwrap(okUUID(from: socketCommand("new_surface --type=terminal"))) + + XCTAssertEqual( + socketCommand("report_pwd /tmp/\(hiddenSurfaceToken) --tab=\(secondaryWorkspaceId) --panel=\(hiddenSurfaceId)"), + "OK" + ) + XCTAssertEqual(socketCommand("focus_surface \(initialSurfaceId)"), "OK") + XCTAssertEqual( + socketCommand("report_pwd /tmp/\(visibleSurfaceToken) --tab=\(secondaryWorkspaceId) --panel=\(initialSurfaceId)"), + "OK" + ) + XCTAssertEqual(socketCommand("select_workspace 0"), "OK") + XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK") + + RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + + openCommandPalette(app: app, query: hiddenSurfaceToken) + let disabledSnapshot = try XCTUnwrap( + waitForCommandPaletteSnapshot(windowId: mainWindowId, query: hiddenSurfaceToken, timeout: 5.0) { snapshot in + self.commandPaletteResultRows(from: snapshot).isEmpty + } + ) + XCTAssertEqual(commandPaletteResultRows(from: disabledSnapshot).count, 0) + dismissCommandPalette(app: app) + + focusSettingsWindow(app: app) + let toggle = try requireSearchAllSurfacesToggle(app: app) + if !toggleIsOn(toggle) { + toggle.click() + } + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { + toggle.exists && toggleIsOn(toggle) + }, + "Expected the all-surfaces search setting to be enabled" + ) + + XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK") + + openCommandPalette(app: app, query: hiddenSurfaceToken) + let enabledSnapshot = try XCTUnwrap( + waitForCommandPaletteSnapshot(windowId: mainWindowId, query: hiddenSurfaceToken, timeout: 5.0) { snapshot in + self.commandPaletteResultRows(from: snapshot).contains { row in + let commandId = row["command_id"] as? String ?? "" + let trailingLabel = row["trailing_label"] as? String ?? "" + return commandId.hasPrefix("switcher.surface.") && trailingLabel == "Terminal" + } + } + ) + + XCTAssertTrue( + commandPaletteResultRows(from: enabledSnapshot).contains { row in + let commandId = row["command_id"] as? String ?? "" + let trailingLabel = row["trailing_label"] as? String ?? "" + return commandId.hasPrefix("switcher.surface.") && trailingLabel == "Terminal" + }, + "Expected Cmd+P to surface the hidden terminal when all-surfaces search is enabled. snapshot=\(enabledSnapshot)" + ) + } + + private func launchAndActivate(_ app: XCUIApplication) { + app.launch() + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 4.0) { + guard app.state != .runningForeground else { return true } + app.activate() + return app.state == .runningForeground + }, + "App did not reach runningForeground before UI interactions" + ) + } + + private func openCommandPalette(app: XCUIApplication, query: String) { + let searchField = app.textFields["CommandPaletteSearchField"] + app.typeKey("p", modifierFlags: [.command]) + XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field") + searchField.click() + searchField.typeText(query) + } + + private func openCommandPaletteCommands(app: XCUIApplication) { + let searchField = app.textFields["CommandPaletteSearchField"] + app.typeKey("p", modifierFlags: [.command, .shift]) + XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field") + searchField.click() + } + + private func dismissCommandPalette(app: XCUIApplication) { + let searchField = app.textFields["CommandPaletteSearchField"] + for _ in 0..<2 { + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + if sidebarHelpPollUntil(timeout: 1.0) { !searchField.exists } { + return + } + } + XCTAssertFalse(searchField.exists, "Expected command palette to dismiss") + } + + private func focusSettingsWindow(app: XCUIApplication) { + app.typeKey(",", modifierFlags: [.command]) + } + + private func requireSearchAllSurfacesToggle(app: XCUIApplication) throws -> XCUIElement { + let toggleId = "CommandPaletteSearchAllSurfacesToggle" + let scrollView = app.scrollViews.firstMatch + let candidates = [ + app.switches[toggleId], + app.checkBoxes[toggleId], + app.buttons[toggleId], + app.otherElements[toggleId], + ] + + for _ in 0..<8 { + if let element = firstExistingElement(candidates: candidates, timeout: 0.4), element.isHittable { + return element + } + if scrollView.exists { + scrollView.swipeUp() + } + } + + throw XCTSkip("Could not find the command palette all-surfaces toggle") + } + + private func toggleIsOn(_ element: XCUIElement) -> Bool { + let value = String(describing: element.value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return value == "1" || value == "true" || value == "on" + } + + private func firstExistingElement( + candidates: [XCUIElement], + timeout: TimeInterval + ) -> XCUIElement? { + var match: XCUIElement? + let found = sidebarHelpPollUntil(timeout: timeout) { + for candidate in candidates where candidate.exists { + match = candidate + return true + } + return false + } + return found ? match : nil + } + + private func waitForSocketPong(timeout: TimeInterval) -> Bool { + sidebarHelpPollUntil(timeout: timeout) { + socketCommand("ping") == "PONG" + } + } + + private func waitForSurfaceIDs(minimumCount: Int, timeout: TimeInterval) -> [String] { + var ids: [String] = [] + let found = sidebarHelpPollUntil(timeout: timeout) { + ids = surfaceIDs() + return ids.count >= minimumCount + } + return found ? ids : surfaceIDs() + } + + private func surfaceIDs() -> [String] { + guard let response = socketCommand("list_surfaces"), !response.isEmpty, !response.hasPrefix("No surfaces") else { + return [] + } + return response + .split(separator: "\n") + .compactMap { line in + guard let range = line.range(of: ": ") else { return nil } + return String(line[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + private func okUUID(from response: String?) -> String? { + guard let response, response.hasPrefix("OK ") else { return nil } + let value = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + return UUID(uuidString: value) != nil ? value : nil + } + + private func socketCommand(_ command: String) -> String? { + ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendLine(command) + } + + private func commandPaletteResultRows(from snapshot: [String: Any]) -> [[String: Any]] { + snapshot["results"] as? [[String: Any]] ?? [] + } + + private func waitForCommandPaletteSnapshot( + windowId: String, + mode: String = "switcher", + query: String, + timeout: TimeInterval, + predicate: (([String: Any]) -> Bool)? = nil + ) -> [String: Any]? { + var latest: [String: Any]? + let matched = sidebarHelpPollUntil(timeout: timeout) { + guard let snapshot = commandPaletteSnapshot(windowId: windowId) else { return false } + latest = snapshot + guard (snapshot["visible"] as? Bool) == true else { return false } + guard (snapshot["mode"] as? String) == mode else { return false } + guard (snapshot["query"] as? String) == query else { return false } + return predicate?(snapshot) ?? true + } + return matched ? latest : latest + } + + private func commandPaletteSnapshot(windowId: String) -> [String: Any]? { + let envelope = socketJSON( + method: "debug.command_palette.results", + params: [ + "window_id": windowId, + "limit": 20, + ] + ) + guard let ok = envelope?["ok"] as? Bool, ok else { return nil } + return envelope?["result"] as? [String: Any] + } + + private func socketJSON(method: String, params: [String: Any]) -> [String: Any]? { + let request: [String: Any] = [ + "id": UUID().uuidString, + "method": method, + "params": params, + ] + return ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendJSON(request) + } + + private final class ControlSocketClient { + private let path: String + private let responseTimeout: TimeInterval + + init(path: String, responseTimeout: TimeInterval) { + self.path = path + self.responseTimeout = responseTimeout + } + + func sendJSON(_ object: [String: Any]) -> [String: Any]? { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object), + let line = String(data: data, encoding: .utf8), + let response = sendLine(line), + let responseData = response.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else { + return nil + } + return parsed + } + + func sendLine(_ line: String) -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + +#if os(macOS) + var noSigPipe: Int32 = 1 + _ = withUnsafePointer(to: &noSigPipe) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_NOSIGPIPE, + ptr, + socklen_t(MemoryLayout.size) + ) + } +#endif + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.size) + addr.sun_family = sa_family_t(AF_UNIX) + + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + let bytes = Array(path.utf8CString) + guard bytes.count <= maxLen else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + memset(raw, 0, maxLen) + for index in 0...offset(of: \.sun_path) ?? 0 + let addrLen = socklen_t(pathOffset + bytes.count) +#if os(macOS) + addr.sun_len = UInt8(min(Int(addrLen), 255)) +#endif + + let connected = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + connect(fd, sa, addrLen) + } + } + guard connected == 0 else { return nil } + + let payload = line + "\n" + let wrote: Bool = payload.withCString { cString in + var remaining = strlen(cString) + var pointer = UnsafeRawPointer(cString) + while remaining > 0 { + let written = write(fd, pointer, remaining) + if written <= 0 { return false } + remaining -= written + pointer = pointer.advanced(by: written) + } + return true + } + guard wrote else { return nil } + + let deadline = Date().addingTimeInterval(responseTimeout) + var buffer = [UInt8](repeating: 0, count: 4096) + var accumulator = "" + while Date() < deadline { + var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let ready = poll(&pollDescriptor, 1, 100) + if ready < 0 { + return nil + } + if ready == 0 { + continue + } + let count = read(fd, &buffer, buffer.count) + if count <= 0 { break } + if let chunk = String(bytes: buffer[0..] --output + +Options: + --universal Build a universal macOS helper (arm64 + x86_64). + --target + Build a single target, e.g. `aarch64-macos` or `x86_64-macos`. + --output Destination path for the built helper. +EOF +} + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +GHOSTTY_DIR="$REPO_ROOT/ghostty" + +OUTPUT_PATH="" +TARGET_TRIPLE="" +UNIVERSAL="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --universal) + UNIVERSAL="true" + shift + ;; + --target) + TARGET_TRIPLE="${2:-}" + shift 2 + ;; + --output) + OUTPUT_PATH="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$OUTPUT_PATH" ]]; then + echo "Missing required --output path" >&2 + usage >&2 + exit 1 +fi + +if [[ "$UNIVERSAL" == "true" && -n "$TARGET_TRIPLE" ]]; then + echo "--universal and --target are mutually exclusive" >&2 + usage >&2 + exit 1 +fi + +if [[ -n "$TARGET_TRIPLE" ]]; then + case "$TARGET_TRIPLE" in + aarch64-macos|x86_64-macos) + ;; + *) + echo "Unsupported --target value: $TARGET_TRIPLE" >&2 + exit 1 + ;; + esac +fi + +if ! command -v zig >/dev/null 2>&1; then + echo "error: zig is required to build the Ghostty CLI helper" >&2 + exit 1 +fi + +if [[ ! -f "$GHOSTTY_DIR/build.zig" ]]; then + echo "error: Ghostty submodule is missing at $GHOSTTY_DIR" >&2 + exit 1 +fi + +build_helper() { + local prefix="$1" + local target="${2:-}" + local args=( + zig build + cli-helper + -Dapp-runtime=none + -Demit-macos-app=false + -Demit-xcframework=false + -Doptimize=ReleaseFast + --prefix + "$prefix" + ) + + if [[ -n "$target" ]]; then + args+=("-Dtarget=$target") + fi + + ( + cd "$GHOSTTY_DIR" + "${args[@]}" + ) +} + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-ghostty-helper.XXXXXX")" +trap 'rm -rf "$TMP_DIR"' EXIT + +mkdir -p "$(dirname "$OUTPUT_PATH")" + +if [[ "$UNIVERSAL" == "true" ]]; then + ARM64_PREFIX="$TMP_DIR/arm64" + X86_PREFIX="$TMP_DIR/x86_64" + build_helper "$ARM64_PREFIX" "aarch64-macos" + build_helper "$X86_PREFIX" "x86_64-macos" + /usr/bin/lipo -create \ + "$ARM64_PREFIX/bin/ghostty" \ + "$X86_PREFIX/bin/ghostty" \ + -output "$OUTPUT_PATH" +else + SINGLE_PREFIX="$TMP_DIR/single" + build_helper "$SINGLE_PREFIX" "$TARGET_TRIPLE" + install -m 755 "$SINGLE_PREFIX/bin/ghostty" "$OUTPUT_PATH" +fi + +chmod +x "$OUTPUT_PATH" diff --git a/scripts/build-sign-upload.sh b/scripts/build-sign-upload.sh index 08d1f84c..86ec511e 100755 --- a/scripts/build-sign-upload.sh +++ b/scripts/build-sign-upload.sh @@ -74,6 +74,12 @@ rm -rf build/ xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build 2>&1 | tail -5 echo "Build succeeded" +HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" +if [ ! -x "$HELPER_PATH" ]; then + echo "Ghostty theme picker helper not found at $HELPER_PATH" >&2 + exit 1 +fi + # --- Inject Sparkle keys --- echo "Injecting Sparkle keys..." SPARKLE_PUBLIC_KEY_DERIVED=$(swift scripts/derive_sparkle_public_key.swift "$SPARKLE_PRIVATE_KEY") @@ -90,6 +96,9 @@ CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" if [ -f "$CLI_PATH" ]; then /usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$CLI_PATH" fi +if [ -f "$HELPER_PATH" ]; then + /usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$HELPER_PATH" +fi /usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" echo "Codesign verified" diff --git a/tests/test_bundled_ghostty_theme_picker_helper.sh b/tests/test_bundled_ghostty_theme_picker_helper.sh new file mode 100755 index 00000000..94a19911 --- /dev/null +++ b/tests/test_bundled_ghostty_theme_picker_helper.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE_PACKAGES_DIR="${CMUX_SOURCE_PACKAGES_DIR:-$PWD/.ci-source-packages}" +DERIVED_DATA_PATH="${CMUX_DERIVED_DATA_PATH:-$PWD/.ci-bundled-ghostty-helper}" +CONFIGURATION="${CMUX_CONFIGURATION:-Debug}" + +case "$CONFIGURATION" in + Debug) + APP_NAME="cmux DEV.app" + ;; + Release) + APP_NAME="cmux.app" + ;; + *) + echo "FAIL: unsupported configuration $CONFIGURATION" >&2 + exit 1 + ;; +esac + +mkdir -p "$SOURCE_PACKAGES_DIR" +rm -rf "$DERIVED_DATA_PATH" + +xcodebuild \ + -project GhosttyTabs.xcodeproj \ + -scheme cmux \ + -configuration "$CONFIGURATION" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + -destination "platform=macOS" \ + build + +APP_PATH="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME" +HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" + +if [ ! -x "$HELPER_PATH" ]; then + echo "FAIL: bundled Ghostty theme picker helper missing at $HELPER_PATH" >&2 + exit 1 +fi + +echo "PASS: bundled Ghostty theme picker helper present at $HELPER_PATH" diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 7763bd76..2ad1298b 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -139,10 +139,22 @@ def test_live_socket_injects_supported_hooks(failures: list[str]) -> None: settings = parse_settings_arg(real_argv) hooks = settings.get("hooks", {}) - expect(set(hooks.keys()) == {"SessionStart", "Stop", "Notification"}, f"unexpected hook keys: {hooks.keys()}", failures) - serialized = json.dumps(settings, sort_keys=True) - expect("UserPromptSubmit" not in serialized, "UserPromptSubmit hook should not be injected", failures) - expect("prompt-submit" not in serialized, "prompt-submit subcommand should not be injected", failures) + expected_hooks = {"SessionStart", "Stop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse"} + expect(set(hooks.keys()) == expected_hooks, f"unexpected hook keys: {hooks.keys()}, expected {expected_hooks}", failures) + # PreToolUse should be async to avoid blocking tool execution + pre_tool_use_hooks = hooks.get("PreToolUse", [{}])[0].get("hooks", [{}]) + expect( + any(h.get("async") is True for h in pre_tool_use_hooks), + f"PreToolUse hook should have async:true, got {pre_tool_use_hooks}", + failures, + ) + # SessionEnd should have a short timeout (session is exiting) + session_end_hooks = hooks.get("SessionEnd", [{}])[0].get("hooks", [{}]) + expect( + any(h.get("timeout", 999) <= 2 for h in session_end_hooks), + f"SessionEnd hook should have short timeout, got {session_end_hooks}", + failures, + ) def test_missing_socket_skips_hook_injection(failures: list[str]) -> None: diff --git a/tests/test_issue_1138_sidebar_pr_polling.py b/tests/test_issue_1138_sidebar_pr_polling.py index 973e98dd..9ff9db2e 100644 --- a/tests/test_issue_1138_sidebar_pr_polling.py +++ b/tests/test_issue_1138_sidebar_pr_polling.py @@ -10,6 +10,9 @@ Validates that shell integration: 4) recovers when a gh probe wedges longer than the async timeout 5) keeps polling in bash after prompt-render helper commands run 6) tears down the timed-out gh probe instead of leaking it in the background +7) falls back to explicit branch lookup when implicit gh branch resolution fails +8) does not clear an existing PR badge on the first prompt while establishing + the HEAD baseline """ from __future__ import annotations @@ -77,6 +80,11 @@ def _git_stub() -> str: exit 0 fi + if [ "$1" = "remote" ] && [ "$2" = "get-url" ] && [ "$3" = "origin" ]; then + printf 'https://github.com/manaflow-ai/cmux.git\\n' + exit 0 + fi + if [ "$1" = "status" ] && [ "$2" = "--porcelain" ] && [ "$3" = "-uno" ]; then exit 0 fi @@ -111,6 +119,17 @@ def _gh_stub() -> str: exit 9 fi + requested_branch="" + if [ $# -ge 3 ]; then + case "$3" in + --*) + ;; + *) + requested_branch="$3" + ;; + esac + fi + branch="" if [ -f "$head_file" ]; then head_line="$(cat "$head_file")" @@ -125,6 +144,9 @@ def _gh_stub() -> str: prompt_helper_idle) printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' ;; + initial_prompt_preserves_pr_badge) + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + ;; transient_same_context) if [ "$count" -eq 1 ]; then printf 'rate limit exceeded\\n' >&2 @@ -154,6 +176,18 @@ def _gh_stub() -> str: fi printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' ;; + explicit_branch_fallback) + if [ -z "$requested_branch" ]; then + printf 'no pull requests found for branch "%s"\\n' "$branch" >&2 + exit 1 + fi + if [ "$requested_branch" = "$branch" ]; then + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + exit 0 + fi + printf 'unexpected branch lookup: %s\\n' "$requested_branch" >&2 + exit 8 + ;; *) printf 'unknown scenario: %s\\n' "$scenario" >&2 exit 2 @@ -198,6 +232,20 @@ def _shell_command(kind: str, scenario: str) -> str: 'sleep 4\n' '_cmux_cleanup\n' ), + "explicit_branch_fallback": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=10\n' + '_cmux_prompt_entry\n' + 'sleep 2\n' + '_cmux_cleanup\n' + ), + "initial_prompt_preserves_pr_badge": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=10\n' + '_cmux_prompt_entry\n' + 'sleep 2\n' + '_cmux_cleanup\n' + ), }[scenario] if kind == "zsh": @@ -344,6 +392,27 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc return (1, f"{shell}/{scenario}: timed-out gh probe still running as pid {gh_pid}") return (0, f"{shell}/{scenario}: ok") + if scenario == "explicit_branch_fallback": + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) + if not any(line.startswith("pr view feature/issue-1138 ") for line in gh_args_lines): + return ( + 1, + f"{shell}/{scenario}: expected explicit branch fallback\n" + "\n".join(gh_args_lines), + ) + return (0, f"{shell}/{scenario}: ok") + + if scenario == "initial_prompt_preserves_pr_badge": + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) + if any(line.startswith("clear_pr ") for line in send_lines): + return ( + 1, + f"{shell}/{scenario}: initial prompt should not clear an existing PR badge\n" + + "\n".join(send_lines), + ) + return (0, f"{shell}/{scenario}: ok") + return (1, f"{shell}/{scenario}: unhandled scenario") @@ -358,6 +427,8 @@ def main() -> int: "transient_same_context", "branch_switch_clear", "timeout_recovery", + "explicit_branch_fallback", + "initial_prompt_preserves_pr_badge", ] base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}" diff --git a/vendor/bonsplit b/vendor/bonsplit index 73c1ef2d..02fa188c 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826 +Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795 diff --git a/web/app/[locale]/docs/notifications/page.tsx b/web/app/[locale]/docs/notifications/page.tsx index 2a5d53b9..b02de0e1 100644 --- a/web/app/[locale]/docs/notifications/page.tsx +++ b/web/app/[locale]/docs/notifications/page.tsx @@ -158,7 +158,7 @@ printf '\\e]99;i=1;e=1;d=1;p=body:All tests passed\\e\\\\'`} [ -S /tmp/cmux.sock ] || exit 0 EVENT=$(cat) -EVENT_TYPE=$(echo "$EVENT" | jq -r '.event // "unknown"') +EVENT_TYPE=$(echo "$EVENT" | jq -r '.hook_event_name // "unknown"') TOOL=$(echo "$EVENT" | jq -r '.tool_name // ""') case "$EVENT_TYPE" in @@ -174,11 +174,26 @@ esac`}

{t("configureClaude")}

{`{ "hooks": { - "Stop": ["~/.claude/hooks/cmux-notify.sh"], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/cmux-notify.sh" + } + ] + } + ], "PostToolUse": [ { "matcher": "Task", - "hooks": ["~/.claude/hooks/cmux-notify.sh"] + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/cmux-notify.sh" + } + ] } ] }