merge: sync markdown viewer with main
This commit is contained in:
+12
-6
@@ -146,19 +146,20 @@ jobs:
|
||||
name: Download pre-built GhosttyKit.xcframework
|
||||
no_output_timeout: 35m
|
||||
command: ./scripts/download-prebuilt-ghosttykit.sh
|
||||
- install-zig
|
||||
- resolve-swift-packages:
|
||||
scheme: cmux-unit
|
||||
- run:
|
||||
name: Run unit tests
|
||||
no_output_timeout: 35m
|
||||
command: |
|
||||
export CMUX_SKIP_ZIG_BUILD=1
|
||||
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||
run_unit_tests() {
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
|
||||
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||
-disableAutomaticPackageResolution \
|
||||
-destination "platform=macOS" \
|
||||
CMUX_SKIP_ZIG_BUILD=1 \
|
||||
-skip-testing:cmuxTests/AppDelegateShortcutRoutingTests/testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace \
|
||||
test 2>&1
|
||||
}
|
||||
@@ -178,7 +179,8 @@ jobs:
|
||||
mkdir -p "$SOURCE_PACKAGES_DIR"
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
|
||||
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||
-resolvePackageDependencies
|
||||
-resolvePackageDependencies \
|
||||
CMUX_SKIP_ZIG_BUILD=1
|
||||
set +e
|
||||
run_unit_tests | tee /tmp/test-output.txt
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
@@ -199,7 +201,8 @@ jobs:
|
||||
name: Run bundled Ghostty theme picker helper regression
|
||||
no_output_timeout: 35m
|
||||
command: |
|
||||
CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \
|
||||
CMUX_SKIP_ZIG_BUILD=1 \
|
||||
CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \
|
||||
./tests/test_bundled_ghostty_theme_picker_helper.sh
|
||||
- run:
|
||||
name: Run CLI no-socket regressions
|
||||
@@ -237,18 +240,20 @@ jobs:
|
||||
name: Download pre-built GhosttyKit.xcframework
|
||||
no_output_timeout: 35m
|
||||
command: ./scripts/download-prebuilt-ghosttykit.sh
|
||||
- install-zig
|
||||
- resolve-swift-packages:
|
||||
scheme: cmux
|
||||
- run:
|
||||
name: Build app and capture warnings
|
||||
no_output_timeout: 35m
|
||||
command: |
|
||||
export CMUX_SKIP_ZIG_BUILD=1
|
||||
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
|
||||
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||
-disableAutomaticPackageResolution \
|
||||
-destination "platform=macOS" build 2>&1 | tee /tmp/cmux-build-output.txt
|
||||
-destination "platform=macOS" \
|
||||
CMUX_SKIP_ZIG_BUILD=1 \
|
||||
build 2>&1 | tee /tmp/cmux-build-output.txt
|
||||
- run:
|
||||
name: Validate Swift warning budget
|
||||
command: python3 scripts/swift_warning_budget.py --log /tmp/cmux-build-output.txt
|
||||
@@ -269,7 +274,6 @@ jobs:
|
||||
name: Download pre-built GhosttyKit.xcframework
|
||||
no_output_timeout: 35m
|
||||
command: ./scripts/download-prebuilt-ghosttykit.sh
|
||||
- install-zig
|
||||
- resolve-swift-packages:
|
||||
scheme: cmux
|
||||
source-packages-dir: .spm-cache
|
||||
@@ -277,12 +281,14 @@ jobs:
|
||||
name: Build universal app (Release)
|
||||
no_output_timeout: 35m
|
||||
command: |
|
||||
export CMUX_SKIP_ZIG_BUILD=1
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Release -derivedDataPath build-universal \
|
||||
-destination "generic/platform=macOS" \
|
||||
-clonedSourcePackagesDirPath .spm-cache \
|
||||
-disableAutomaticPackageResolution \
|
||||
ARCHS="arm64 x86_64" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
CMUX_SKIP_ZIG_BUILD=1 \
|
||||
CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
|
||||
- save-swift-package-cache:
|
||||
prefix: release
|
||||
|
||||
@@ -22,6 +22,9 @@ reviews:
|
||||
- path: "GhosttyTabs.xcodeproj/**"
|
||||
instructions: |
|
||||
Review project wiring against the cmux Swift lint rules. Flag project changes that enable app/runtime code paths which bypass Swift concurrency, logging, localization, or shared action-path expectations.
|
||||
- path: "**/*.{ts,tsx,js,jsx,mjs,cjs,sh,zsh}"
|
||||
instructions: |
|
||||
Apply `.github/review-bot-rules/runtime-no-hacky-sleeps.md` during review. For production runtime, script, and build changes, flag fixed sleeps, timers, delayed dispatch, polling, or wall-clock waits used as synchronization. Pass for tests, pure presentation timing, dedicated cancellation-aware retry/timeout abstractions with tests, and existing delay code not worsened.
|
||||
|
||||
pre_merge_checks:
|
||||
custom_checks:
|
||||
@@ -33,6 +36,10 @@ reviews:
|
||||
mode: error
|
||||
instructions: |
|
||||
For production Swift changes, fail when the diff introduces or materially expands blocking or timing-based synchronization from `.github/review-bot-rules/swift-blocking-runtime.md`: semaphores, blocking waits, sleeps, delayed dispatch, polling, main-queue sync, or manual locks where an actor or explicit signal should own synchronization. Pass for deterministic test-only scaffolding and short user-visible UI animation delays that do not use `Task.sleep`.
|
||||
- name: "cmux no hacky sleeps"
|
||||
mode: error
|
||||
instructions: |
|
||||
For production non-Swift app/runtime changes in TypeScript, JavaScript, shell, or build/runtime scripts, fail when the diff violates `.github/review-bot-rules/runtime-no-hacky-sleeps.md`: fixed sleeps, delayed dispatch, timers, polling, or wall-clock waits used to paper over lifecycle, focus, rendering, socket, process, filesystem, network, teardown, startup, retry, or shared-state races. Swift sleeps are covered by `cmux Swift blocking runtime`. Pass for deterministic test-only scaffolding, purely presentation animation or progress timing, dedicated cancellation-aware retry/timeout abstractions with tests, and existing delay code not worsened.
|
||||
- name: "cmux Swift concurrency"
|
||||
mode: error
|
||||
instructions: |
|
||||
@@ -57,3 +64,7 @@ reviews:
|
||||
mode: error
|
||||
instructions: |
|
||||
For Swift architecture changes, fail when the diff violates `.github/review-bot-rules/swift-architectural-rethink.md`: symptom patches using sleeps, delayed dispatch, polling, locks, observers, side channels, duplicate entrypoint wiring, or split UI lifecycle ownership that leaves bad state representable. Pass for small correctness fixes with clear owners and invariants, required platform bridges, and test-only synchronization.
|
||||
- name: "cmux Swift auxiliary window close shortcuts"
|
||||
mode: error
|
||||
instructions: |
|
||||
For Swift changes that add or materially change standalone cmux-owned windows, fail when the diff violates `.github/review-bot-rules/swift-auxiliary-window-close-shortcuts.md`: user-visible NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup code without a stable cmux.* identifier and shared close-shortcut ownership through cmuxAuxiliaryWindowIdentifiers. Pass for main workspace windows, terminal panes, tabs, sheets, popovers, menus, test-only fixtures, and existing unregistered windows not worsened by the PR. If the deterministic CI script already catches the literal assignment, mention scripts/lint_auxiliary_window_close_shortcuts.py; otherwise explain the broader review-only pattern.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# cmux Review Bot Rules
|
||||
|
||||
These rules are the shared source of truth for Greptile and CodeRabbit custom Swift review behavior.
|
||||
These rules are the shared source of truth for Greptile and CodeRabbit custom review behavior.
|
||||
|
||||
The rule files are intentionally short and focused. Each one defines one class of issue, concrete failure cases, allowed cases, and the expected reporting shape. Keep new rules narrow enough that a reviewer can apply the rule to a full PR diff without turning it into a broad style guide.
|
||||
|
||||
@@ -8,8 +8,10 @@ Greptile is configured to publish a GitHub status check and inline findings. Cod
|
||||
|
||||
Current rules:
|
||||
|
||||
- `runtime-no-hacky-sleeps.md`
|
||||
- `swift-actor-isolation.md`
|
||||
- `swift-architectural-rethink.md`
|
||||
- `swift-auxiliary-window-close-shortcuts.md`
|
||||
- `swift-blocking-runtime.md`
|
||||
- `swift-concurrency-modernization.md`
|
||||
- `swift-concurrent-annotation.md`
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Runtime No Hacky Sleeps
|
||||
|
||||
Scope: TypeScript, JavaScript, shell, and non-Swift runtime scripts. Swift timing and blocking primitives are covered by `swift-blocking-runtime.md`.
|
||||
|
||||
Flag fixed delays used as synchronization in production application or runtime code.
|
||||
|
||||
Report a failure when the diff introduces or materially expands any of these in non-test code:
|
||||
|
||||
- `sleep`, `usleep`, shell `sleep`, `setTimeout`, `setInterval`, timers, polling loops, or fixed backoff used to make lifecycle, focus, rendering, socket, process, filesystem, network, or shared-state readiness appear reliable.
|
||||
- Delay comments or names such as "give it time", "settle", "wait a bit", "wait for readiness", or "avoid race" without a real event from the owner that knows readiness.
|
||||
- Retrying, teardown, startup, keepalive, debounce, or handoff logic that depends on elapsed wall-clock time instead of a cancellation-aware scheduler, callback, notification, file descriptor or process event, async sequence, state transition, or explicit completion point.
|
||||
|
||||
Allowed cases:
|
||||
|
||||
- Deterministic sleeps in tests or explicit test-only scaffolding.
|
||||
- User-visible animation or progress timing where the timer is purely presentation and not coordination.
|
||||
- Production retry or timeout logic implemented through a dedicated cancellation-aware abstraction with bounded deadlines and tests, where the delay is part of the product behavior rather than a race repair.
|
||||
- Existing delay code that the PR does not introduce or worsen.
|
||||
|
||||
Do not accept a sleep or fixed delay because it is short, only runs once, or seems to fix a flaky repro. A correct fix names the owner, invariant, and real signal that makes the next state valid.
|
||||
|
||||
When reporting, identify the changed delay, the race or lifecycle gap it hides, and the event or owner that should replace it.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Swift Auxiliary Window Close Shortcuts
|
||||
|
||||
Standalone cmux-owned windows must have one close-shortcut owner so Cmd+W closes or hides the active window instead of falling through to workspace panel closing.
|
||||
|
||||
Report a failure when the diff introduces or materially changes:
|
||||
|
||||
- A user-visible `NSWindow`, `NSPanel`, `NSWindowController`, SwiftUI `Window`, or SwiftUI `WindowGroup` without a stable `cmux.*` window identifier.
|
||||
- A `cmux.*` window identifier assignment that is missing from `cmuxAuxiliaryWindowIdentifiers` in `Sources/cmuxApp.swift`.
|
||||
- A new standalone debug, settings, preview, task, editor, browser, file, import, config, or inspector window that can become key but is not covered by `cmuxWindowShouldOwnCloseShortcut`.
|
||||
- A custom Cmd+W, `performKeyEquivalent`, or close-menu workaround that bypasses the shared `cmuxWindowShouldOwnCloseShortcut` routing instead of registering the window identifier.
|
||||
|
||||
Allowed cases:
|
||||
|
||||
- Main workspace windows, terminal panes, tabs, sheets, popovers, menus, and views that are not standalone key windows.
|
||||
- Hidden bootstrap or internal windows explicitly documented in the script ignore list.
|
||||
- Test-only fixture windows.
|
||||
- Existing unregistered windows that the PR does not introduce or worsen, though mention them if they are adjacent to the changed window code.
|
||||
|
||||
When reporting, include the window/controller/file, the missing identifier or owner registration, and the expected shared path: assign a stable `cmux.*` identifier and register user-closable windows in `cmuxAuxiliaryWindowIdentifiers`. If the hard CI lint already catches the exact literal assignment, point to `scripts/lint_auxiliary_window_close_shortcuts.py`; otherwise explain why the bot rule caught a more flexible pattern.
|
||||
@@ -51,11 +51,20 @@ jobs:
|
||||
- name: Validate Swift file length budget guard
|
||||
run: ./tests/test_ci_swift_file_length_budget.sh
|
||||
|
||||
- name: Validate auxiliary window close shortcut lint
|
||||
run: ./tests/test_ci_auxiliary_window_close_shortcuts.sh
|
||||
|
||||
- name: Validate CircleCI auto approval
|
||||
run: python3 tests/test_circleci_auto_approve.py
|
||||
|
||||
- name: Validate Swift file length budget
|
||||
run: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv
|
||||
# Paused: stale-base merge races (two PRs each fitting the budget can
|
||||
# overshoot when merged back-to-back without rebasing). CodeRabbit and
|
||||
# Greptile already flag large-file growth on PRs. Re-enable by uncommenting
|
||||
# this step (and refresh the budget tsv first):
|
||||
# python3 scripts/swift_file_length_budget.py \
|
||||
# --budget .github/swift-file-length-budget.tsv --write-budget
|
||||
# - name: Validate Swift file length budget
|
||||
# run: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv
|
||||
|
||||
remote-daemon-tests:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -287,6 +296,9 @@ jobs:
|
||||
CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \
|
||||
./tests/test_bundled_ghostty_theme_picker_helper.sh
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
|
||||
- name: Run CLI no-socket regressions
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -308,6 +320,7 @@ jobs:
|
||||
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_operation_deadline.py
|
||||
python3 tests/test_claude_wrapper_hooks.py
|
||||
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_stop_last_assistant.py
|
||||
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_extension_install.py
|
||||
|
||||
tests-build-and-lag:
|
||||
# Build the full cmux scheme and run the lag regression on WarpBuild.
|
||||
|
||||
@@ -286,6 +286,13 @@ jobs:
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName cmux NIGHTLY" "$app_plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$app_plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${bundle_id}" "$app_plist"
|
||||
local url_type_name
|
||||
url_type_name="$(/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes:1:CFBundleURLName" "$app_plist")"
|
||||
if [[ "$url_type_name" != *.auth ]]; then
|
||||
echo "Expected CFBundleURLTypes[1] to be the auth URL type, found: $url_type_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleURLTypes:1:CFBundleURLSchemes:0 cmux-nightly" "$app_plist"
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$app_plist" >/dev/null 2>&1 || true
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$app_plist" >/dev/null 2>&1 || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$app_plist"
|
||||
|
||||
@@ -36,7 +36,7 @@ concurrency:
|
||||
jobs:
|
||||
activation-session:
|
||||
runs-on: ${{ inputs.runner || 'warp-macos-15-arm64-6x' }}
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
PERF_TAG: perfci
|
||||
steps:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"triggerOnUpdates": true,
|
||||
"statusCheck": true,
|
||||
"updateExistingSummaryComment": true,
|
||||
"instructions": "Apply cmux's custom Swift lint rules from .github/review-bot-rules/ during review. Treat those files as the source of truth. Focus on production Swift changes and ignore test-only scaffolding unless it makes production behavior worse. Do not treat PR-head edits to these rule files as weakening the rules until those edits are merged.",
|
||||
"instructions": "Apply cmux's custom review rules from .github/review-bot-rules/ during review. Treat those files as the source of truth. Focus on production Swift and runtime changes, and ignore test-only scaffolding unless it makes production behavior worse. Do not treat PR-head edits to these rule files as weakening the rules until those edits are merged.",
|
||||
"rules": [
|
||||
{
|
||||
"id": "cmux-swift-actor-isolation",
|
||||
@@ -18,6 +18,12 @@
|
||||
"scope": ["**/*.swift", "**/Package.swift"],
|
||||
"severity": "high"
|
||||
},
|
||||
{
|
||||
"id": "cmux-runtime-no-hacky-sleeps",
|
||||
"rule": "Flag fixed sleeps, delayed dispatch, timers, polling, or wall-clock waits used as synchronization in production non-Swift app/runtime code across TypeScript, JavaScript, shell, or build/runtime scripts. Fail race repairs for lifecycle, focus, rendering, socket, process, filesystem, network, teardown, startup, retry, or shared-state readiness unless they use a real signal or a dedicated cancellation-aware timeout/retry abstraction with tests. Swift files are covered by cmux-swift-blocking-runtime.",
|
||||
"scope": ["web/app/**/*.ts", "web/app/**/*.tsx", "web/services/**/*.ts", "web/services/**/*.tsx", "web/db/**/*.ts", "web/data/**/*.ts", "web/i18n/**/*.ts", "web/scripts/**/*.ts", "web/scripts/**/*.tsx", "web/scripts/**/*.js", "web/scripts/**/*.jsx", "web/scripts/**/*.mjs", "web/scripts/**/*.cjs", "web/scripts/**/*.sh", "web/scripts/**/*.zsh", "web/*.ts", "web/*.mjs", "scripts/**/*.ts", "scripts/**/*.tsx", "scripts/**/*.js", "scripts/**/*.jsx", "scripts/**/*.mjs", "scripts/**/*.cjs", "scripts/**/*.sh", "scripts/**/*.zsh"],
|
||||
"severity": "high"
|
||||
},
|
||||
{
|
||||
"id": "cmux-swift-concurrency-modernization",
|
||||
"rule": "Flag new legacy async patterns in cmux-owned Swift where Swift concurrency is the correct shape: DispatchQueue.global for ordinary async work, new Combine app state, completion-handler APIs fully under cmux control, or fire-and-forget Tasks with meaningful lifecycle.",
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
"description": "Source-of-truth cmux lint rule for blocking and timing primitive review.",
|
||||
"scope": ["**/*.swift", "**/Package.swift"]
|
||||
},
|
||||
{
|
||||
"path": ".github/review-bot-rules/runtime-no-hacky-sleeps.md",
|
||||
"description": "Source-of-truth cmux review rule for fixed sleeps, delays, and polling used as runtime synchronization.",
|
||||
"scope": ["web/app/**/*.ts", "web/app/**/*.tsx", "web/services/**/*.ts", "web/services/**/*.tsx", "web/db/**/*.ts", "web/data/**/*.ts", "web/i18n/**/*.ts", "web/scripts/**/*.ts", "web/scripts/**/*.tsx", "web/scripts/**/*.js", "web/scripts/**/*.jsx", "web/scripts/**/*.mjs", "web/scripts/**/*.cjs", "web/scripts/**/*.sh", "web/scripts/**/*.zsh", "web/*.ts", "web/*.mjs", "scripts/**/*.ts", "scripts/**/*.tsx", "scripts/**/*.js", "scripts/**/*.jsx", "scripts/**/*.mjs", "scripts/**/*.cjs", "scripts/**/*.sh", "scripts/**/*.zsh"]
|
||||
},
|
||||
{
|
||||
"path": ".github/review-bot-rules/swift-concurrency-modernization.md",
|
||||
"description": "Source-of-truth cmux lint rule for Swift concurrency modernization review.",
|
||||
|
||||
+4
-3
@@ -1,13 +1,14 @@
|
||||
# cmux Custom Review Rules
|
||||
|
||||
Apply the custom lint rules in `.github/review-bot-rules/` to Swift and Swift project changes.
|
||||
Apply the custom lint rules in `.github/review-bot-rules/` to Swift, runtime, and project changes.
|
||||
|
||||
Greptile should treat the rules in that directory as the source of truth for cmux Swift reviews. PR-head edits to the rule files should not weaken review behavior until the edits are merged into the base branch.
|
||||
Greptile should treat the rules in that directory as the source of truth for cmux reviews. PR-head edits to the rule files should not weaken review behavior until the edits are merged into the base branch.
|
||||
|
||||
Review production Swift changes for:
|
||||
Review production Swift and runtime changes for:
|
||||
|
||||
- Swift actor isolation mistakes.
|
||||
- Blocking runtime primitives and timing-based synchronization.
|
||||
- Fixed sleeps, delays, and polling used as hacky synchronization.
|
||||
- Legacy concurrency patterns where Swift concurrency is available.
|
||||
- Incorrect `@concurrent` or `nonisolated async` behavior.
|
||||
- Swift file sprawl and missing SwiftPM package boundaries for independently testable feature logic.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "HermesAgent.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#101820"/>
|
||||
<path d="M20 47V17h7v12h10V17h7v30h-7V35H27v12h-7Z" fill="#F4F7FA"/>
|
||||
<path d="M15 13h34v6H15z" fill="#4AD7D1"/>
|
||||
<path d="M15 45h34v6H15z" fill="#CFA9FF"/>
|
||||
<path d="M18 13l5-6 5 6M36 13l5-6 5 6M18 51l5 6 5-6M36 51l5 6 5-6" fill="none" stroke="#F4F7FA" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 472 B |
+69
-9
@@ -2,9 +2,76 @@
|
||||
|
||||
All notable changes to cmux are documented here.
|
||||
|
||||
## [0.64.4] - 2026-05-11
|
||||
|
||||
### Added
|
||||
- Add `warnBeforeClosingTab` close-warning toggle to opt back into the close confirmation prompt ([#2808](https://github.com/manaflow-ai/cmux/pull/2808)) -- thanks @dandaka for the report!
|
||||
- Add `cmux browser cookies import` CLI for bringing cookies into cmux browser panes ([#3770](https://github.com/manaflow-ai/cmux/pull/3770))
|
||||
- Add guarded `cmux://ssh` deep links that prompt before launching SSH ([#3677](https://github.com/manaflow-ai/cmux/pull/3677))
|
||||
- Restore Vault Pi agent sessions across relaunch ([#3582](https://github.com/manaflow-ai/cmux/pull/3582), [#3636](https://github.com/manaflow-ai/cmux/pull/3636)) -- thanks @garizs for the report!
|
||||
- Add Hermes Agent hook support ([#3585](https://github.com/manaflow-ai/cmux/pull/3585))
|
||||
- Per-agent toggles for hiding Claude, Codex, OpenCode, Gemini, and Rovo Dev session restore ([#3616](https://github.com/manaflow-ai/cmux/pull/3616))
|
||||
- Add Insert Path and Insert Relative Path context menu items in the file explorer ([#3620](https://github.com/manaflow-ai/cmux/pull/3620))
|
||||
- Restore SSH workspace descriptors on relaunch ([#3576](https://github.com/manaflow-ai/cmux/pull/3576))
|
||||
- Follow SSH workspaces in the Files sidebar so the remote root replaces the local macOS path ([#3721](https://github.com/manaflow-ai/cmux/pull/3721)) -- thanks @Lots-ninety-nine for the report!
|
||||
- Add Welcome sidebar toggle shortcuts ([#3748](https://github.com/manaflow-ai/cmux/pull/3748))
|
||||
|
||||
### Changed
|
||||
- File drop routing now defaults to text with Shift used as the split override.
|
||||
- Allow HTTP localhost subdomains in browser panes ([#3764](https://github.com/manaflow-ai/cmux/pull/3764))
|
||||
- Make browser find shortcuts respect remaps ([#3728](https://github.com/manaflow-ai/cmux/pull/3728))
|
||||
- Make Close Tab remaps own browser popup close ([#3830](https://github.com/manaflow-ai/cmux/pull/3830))
|
||||
- Alias top-level auth commands so `cmux signin` and `cmux signout` work without the `auth` prefix.
|
||||
|
||||
### Fixed
|
||||
- Fix stale terminal foreground after theme switch leaving white-on-white text in running sessions ([#3852](https://github.com/manaflow-ai/cmux/pull/3852))
|
||||
- Fix managed defaults replay overriding user changes after every `cmux.json` reload ([#3847](https://github.com/manaflow-ai/cmux/pull/3847))
|
||||
- Preserve the Claude wrapper dev channel resume flag ([#3752](https://github.com/manaflow-ai/cmux/pull/3752)) -- thanks @Clean-Cole!
|
||||
- Fix SSH browser loopback fetches reaching backends on second forwarded ports ([#3820](https://github.com/manaflow-ai/cmux/pull/3820))
|
||||
- Fix modified Backspace deleting more than one character when an omnibar inline completion is showing ([#3842](https://github.com/manaflow-ai/cmux/pull/3842))
|
||||
- Close Web Inspector before browser host teardown to prevent a UAF crash on pane close ([#3835](https://github.com/manaflow-ai/cmux/pull/3835))
|
||||
- Fix Files sidebar find result aggregation ([#3818](https://github.com/manaflow-ai/cmux/pull/3818))
|
||||
- Fix Escape dismissing the command palette ([#3823](https://github.com/manaflow-ai/cmux/pull/3823))
|
||||
- Resume Claude, Codex, and OpenCode sessions from the session's original cwd.
|
||||
- Fix Close Other Tabs targeting all tabs in the pane right-click menu ([#3628](https://github.com/manaflow-ai/cmux/pull/3628)) -- thanks @flatsponge for the report!
|
||||
- Clear surface notifications during pane teardown so workspace badges don't stay stuck ([#3744](https://github.com/manaflow-ai/cmux/pull/3744))
|
||||
- Fix folder proxy icon drag ([#3804](https://github.com/manaflow-ai/cmux/pull/3804)) -- thanks @lederniermagicien!
|
||||
- Fix right sidebar shortcut defaults ([#3784](https://github.com/manaflow-ai/cmux/pull/3784))
|
||||
- Fix right sidebar titlebar double-click ([#3750](https://github.com/manaflow-ai/cmux/pull/3750))
|
||||
- Fix right sidebar Find typing lag ([#3739](https://github.com/manaflow-ai/cmux/pull/3739))
|
||||
- Route SSH image drops through the terminal text path.
|
||||
- Fix terminal top-row click routing ([#3720](https://github.com/manaflow-ai/cmux/pull/3720))
|
||||
- Fix Mark Workspace as Unread enablement ([#3727](https://github.com/manaflow-ai/cmux/pull/3727)) -- thanks @mfn for the report!
|
||||
- Fix Cmd-W to close Task Manager and auxiliary windows.
|
||||
- Fix command palette arrow keys and no-match flash.
|
||||
- Restore Zhuyin IME candidate marked-text handling ([#3574](https://github.com/manaflow-ai/cmux/pull/3574)) -- thanks @yuanganai for the report!
|
||||
- Fix Task Manager CPU sampling ([#3588](https://github.com/manaflow-ai/cmux/pull/3588))
|
||||
- Fix Cmd+N window size after the last window closes ([#3611](https://github.com/manaflow-ai/cmux/pull/3611)) -- thanks @bigtruth for the report!
|
||||
- Fix Match Terminal Background sidebar toggle snapping back on ([#3635](https://github.com/manaflow-ai/cmux/pull/3635))
|
||||
- Count cmux app RSS in Task Manager totals ([#3587](https://github.com/manaflow-ai/cmux/pull/3587))
|
||||
- Keep Settings layered above the main window ([#3612](https://github.com/manaflow-ai/cmux/pull/3612))
|
||||
- Forward Left/Right arrow keys to the browser surface ([#3663](https://github.com/manaflow-ai/cmux/pull/3663)) -- thanks @kimdane0115 for the report!
|
||||
- Fix Rovo Dev transcript previews ([#3666](https://github.com/manaflow-ai/cmux/pull/3666))
|
||||
|
||||
### Thanks to 12 contributors!
|
||||
|
||||
- [@austinywang](https://github.com/austinywang)
|
||||
- [@bigtruth](https://github.com/bigtruth)
|
||||
- [@Clean-Cole](https://github.com/Clean-Cole)
|
||||
- [@dandaka](https://github.com/dandaka)
|
||||
- [@flatsponge](https://github.com/flatsponge)
|
||||
- [@garizs](https://github.com/garizs)
|
||||
- [@kimdane0115](https://github.com/kimdane0115)
|
||||
- [@lawrencecchen](https://github.com/lawrencecchen)
|
||||
- [@lederniermagicien](https://github.com/lederniermagicien)
|
||||
- [@Lots-ninety-nine](https://github.com/Lots-ninety-nine)
|
||||
- [@mfn](https://github.com/mfn)
|
||||
- [@yuanganai](https://github.com/yuanganai)
|
||||
|
||||
## [0.64.3] - 2026-05-05
|
||||
|
||||
### Added
|
||||
- Added Show in Finder to the workspace sidebar right-click menu.
|
||||
- `cmux config` CLI with `cmux config doctor` for validating `cmux.json` without a socket, plus `cmux config path`, `cmux config docs`, and `cmux config reload` aliases ([#3454](https://github.com/manaflow-ai/cmux/pull/3454))
|
||||
|
||||
### Fixed
|
||||
@@ -49,18 +116,14 @@ All notable changes to cmux are documented here.
|
||||
## [0.64.0] - 2026-05-05
|
||||
|
||||
### Added
|
||||
- Feed sidebar with `cmux feed-hook` and OpenCode plugin to surface permission requests, plan approvals, and agent questions inline ([#3057](https://github.com/manaflow-ai/cmux/pull/3057), [#3405](https://github.com/manaflow-ai/cmux/pull/3405), [#3457](https://github.com/manaflow-ai/cmux/pull/3457))
|
||||
- Sessions panel (renamed Vault) in the right sidebar with persistent session restore and agent resume across relaunch ([#2936](https://github.com/manaflow-ai/cmux/pull/2936), [#2978](https://github.com/manaflow-ai/cmux/pull/2978), [#3259](https://github.com/manaflow-ai/cmux/pull/3259), [#3419](https://github.com/manaflow-ai/cmux/pull/3419), [#3429](https://github.com/manaflow-ai/cmux/pull/3429), [#3487](https://github.com/manaflow-ai/cmux/pull/3487), [#3528](https://github.com/manaflow-ai/cmux/pull/3528))
|
||||
- Restore prior panes and resume Claude Code, Codex, OpenCode, Gemini, and Rovo Dev sessions across relaunch, including when you close the last window with the red X ([#2936](https://github.com/manaflow-ai/cmux/pull/2936), [#2978](https://github.com/manaflow-ai/cmux/pull/2978), [#3259](https://github.com/manaflow-ai/cmux/pull/3259), [#3419](https://github.com/manaflow-ai/cmux/pull/3419), [#3429](https://github.com/manaflow-ai/cmux/pull/3429), [#3487](https://github.com/manaflow-ai/cmux/pull/3487), [#3528](https://github.com/manaflow-ai/cmux/pull/3528), [#3530](https://github.com/manaflow-ai/cmux/pull/3530), [#3535](https://github.com/manaflow-ai/cmux/pull/3535))
|
||||
- Passkey, WebAuthn, and FIDO2 support in browser panes ([#2660](https://github.com/manaflow-ai/cmux/pull/2660), [#2727](https://github.com/manaflow-ai/cmux/pull/2727), [#2905](https://github.com/manaflow-ai/cmux/pull/2905), [#2908](https://github.com/manaflow-ai/cmux/pull/2908))
|
||||
- `cmux vm` CLI and Cloud VM backend for spawning Freestyle-backed cloud workspaces with `cmux vm new`, `cmux vm shell`, and `cmux vm attach` ([#3046](https://github.com/manaflow-ai/cmux/pull/3046), [#3185](https://github.com/manaflow-ai/cmux/pull/3185), [#3196](https://github.com/manaflow-ai/cmux/pull/3196), [#3219](https://github.com/manaflow-ai/cmux/pull/3219), [#3432](https://github.com/manaflow-ai/cmux/pull/3432), [#3437](https://github.com/manaflow-ai/cmux/pull/3437))
|
||||
- Dock right-sidebar TUI control surface with project and global config via `.cmux/dock.json` and `~/.config/cmux/dock.json` ([#3217](https://github.com/manaflow-ai/cmux/pull/3217), [#3366](https://github.com/manaflow-ai/cmux/pull/3366), [#3376](https://github.com/manaflow-ai/cmux/pull/3376), [#3393](https://github.com/manaflow-ai/cmux/pull/3393))
|
||||
- Task Manager window and `cmux top` CLI for window, workspace, pane, surface, and browser webview snapshots ([#3290](https://github.com/manaflow-ai/cmux/pull/3290), [#3471](https://github.com/manaflow-ai/cmux/pull/3471))
|
||||
- Finder-like file explorer sidebar with SSH support ([#1963](https://github.com/manaflow-ai/cmux/pull/1963))
|
||||
- File preview panels in the sidebar ([#3139](https://github.com/manaflow-ai/cmux/pull/3139))
|
||||
- Menu bar only mode ([#3181](https://github.com/manaflow-ai/cmux/pull/3181))
|
||||
- System-wide hotkey to show and hide cmux windows ([#2389](https://github.com/manaflow-ai/cmux/pull/2389))
|
||||
- Cursor and Gemini CLI agent integrations with `setup-hooks` ([#2717](https://github.com/manaflow-ai/cmux/pull/2717))
|
||||
- Gemini and Rovo Dev session hooks with sessions piped into Vault ([#3530](https://github.com/manaflow-ai/cmux/pull/3530), [#3535](https://github.com/manaflow-ai/cmux/pull/3535))
|
||||
- iMessage mode for agent prompts ([#3252](https://github.com/manaflow-ai/cmux/pull/3252))
|
||||
- Settings sidebar shell and unified config utility window with cmux, Ghostty, and synced tabs ([#3024](https://github.com/manaflow-ai/cmux/pull/3024), [#3244](https://github.com/manaflow-ai/cmux/pull/3244), [#3400](https://github.com/manaflow-ai/cmux/pull/3400))
|
||||
- Make `cmux.json` the canonical settings file with JSONC parsing and legacy `settings.json` fallback ([#3409](https://github.com/manaflow-ai/cmux/pull/3409), [#3424](https://github.com/manaflow-ai/cmux/pull/3424))
|
||||
@@ -81,7 +144,6 @@ All notable changes to cmux are documented here.
|
||||
- Korean (ko) localization ([#2885](https://github.com/manaflow-ai/cmux/pull/2885)) -- thanks @say8425!
|
||||
- Opt-in setting to open Cmd-clicked Markdown files in the cmux Markdown viewer ([#2904](https://github.com/manaflow-ai/cmux/pull/2904)) -- thanks @SeongJaeSong!
|
||||
- cmux browser disable switch ([#3256](https://github.com/manaflow-ai/cmux/pull/3256))
|
||||
- Beta feature toggles for Feed and Dock ([#3537](https://github.com/manaflow-ai/cmux/pull/3537))
|
||||
- Markdown and plain-text variants for docs pages plus `/llms.txt` index for agent consumption ([#3410](https://github.com/manaflow-ai/cmux/pull/3410))
|
||||
|
||||
### Changed
|
||||
@@ -100,9 +162,7 @@ All notable changes to cmux are documented here.
|
||||
- Show Codex TUI errors in the sidebar ([#3212](https://github.com/manaflow-ai/cmux/pull/3212))
|
||||
- Keep Cmd-Shift-N windows on the source display ([#3214](https://github.com/manaflow-ai/cmux/pull/3214))
|
||||
- Select find text on repeated Cmd+F ([#3314](https://github.com/manaflow-ai/cmux/pull/3314))
|
||||
- Search Codex rollout content from the sessions sidebar ([#3396](https://github.com/manaflow-ai/cmux/pull/3396))
|
||||
- Disable Claude OSC notifications in the cmux wrapper and gate Claude OSC suppression on integration setting ([#3418](https://github.com/manaflow-ai/cmux/pull/3418), [#3474](https://github.com/manaflow-ai/cmux/pull/3474))
|
||||
- Route Codex permission approvals through Feed ([#3420](https://github.com/manaflow-ai/cmux/pull/3420))
|
||||
- Namespace agent hook CLI commands ([#3298](https://github.com/manaflow-ai/cmux/pull/3298))
|
||||
|
||||
### Fixed
|
||||
@@ -117,7 +177,7 @@ All notable changes to cmux are documented here.
|
||||
- Fix close confirmation bypass when spamming close ([#2989](https://github.com/manaflow-ai/cmux/pull/2989))
|
||||
- Fix multi-workspace close confirmation modality ([#3153](https://github.com/manaflow-ai/cmux/pull/3153))
|
||||
- Fix Cmd/Ctrl shortcut hint parity ([#2994](https://github.com/manaflow-ai/cmux/pull/2994))
|
||||
- Fix Sessions panel CPU loop on nightly and cancel drag on Escape ([#2995](https://github.com/manaflow-ai/cmux/pull/2995), [#3013](https://github.com/manaflow-ai/cmux/pull/3013))
|
||||
- Cancel drag on Escape ([#3013](https://github.com/manaflow-ai/cmux/pull/3013))
|
||||
- Pin regular-weight Japanese auto-fallback face ([#3015](https://github.com/manaflow-ai/cmux/pull/3015))
|
||||
- Fix 100% CPU from ContentView publisher feedback loop ([#3028](https://github.com/manaflow-ai/cmux/pull/3028))
|
||||
- Fix `DebugEventLog` `NSFileHandle` ObjC exception crash ([#3034](https://github.com/manaflow-ai/cmux/pull/3034))
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
|
||||
extension CMUXCLI {
|
||||
// MARK: - Generic agent hook system
|
||||
|
||||
/// Configuration for a hook-based agent integration.
|
||||
struct AgentHookDef {
|
||||
let name: String // CLI name: "cursor", "gemini", etc.
|
||||
let displayName: String // Human-readable: "Cursor", "Gemini"
|
||||
let statusKey: String // Key for set_status: "cursor", "gemini"
|
||||
let configDir: String // Relative to ~: ".cursor", ".gemini"
|
||||
let configFile: String // File name: "hooks.json", "settings.json"
|
||||
let configDirEnvOverride: String? // e.g. "CODEX_HOME" overrides configDir
|
||||
let sessionStoreSuffix: String // e.g. "cursor" -> ~/.cmuxterm/cursor-hook-sessions.json
|
||||
let disableEnvVar: String // e.g. "CMUX_CURSOR_HOOKS_DISABLED"
|
||||
let hookMarker: String // Marker in commands: "cmux hooks cursor"
|
||||
let binaryName: String
|
||||
let format: HookFormat
|
||||
let events: [HookEvent]
|
||||
let aliases: Set<String>
|
||||
/// Feed-hook events. Each entry installs a second hook for
|
||||
/// `agentEvent` that invokes `cmux hooks feed --source <name>`
|
||||
/// with a 120s timeout so the socket reply wait doesn't trip the
|
||||
/// agent's default hook timeout when the user takes time to
|
||||
/// approve/deny a permission / plan / question.
|
||||
let feedHookEvents: [String]
|
||||
let postInstallAction: PostInstallAction?
|
||||
|
||||
enum HookFormat {
|
||||
case flat // Cursor: {"hooks": {"event": [{"command": "..."}]}, "version": 1}
|
||||
case nested(timeoutMs: Int) // Codex/Gemini: nested with type/command/timeout
|
||||
case rovoDevYAML
|
||||
case hermesAgentYAML
|
||||
}
|
||||
|
||||
struct HookEvent {
|
||||
let agentEvent: String
|
||||
let cmuxSubcommand: String
|
||||
}
|
||||
|
||||
enum PostInstallAction {
|
||||
case codexConfigToml // write hooks = true to config.toml on install, remove on uninstall
|
||||
}
|
||||
|
||||
/// Resolves the config directory, respecting env override if set.
|
||||
func resolvedConfigDir() -> String {
|
||||
if let envKey = configDirEnvOverride,
|
||||
let envValue = ProcessInfo.processInfo.environment[envKey],
|
||||
!envValue.isEmpty {
|
||||
return NSString(string: envValue).expandingTildeInPath
|
||||
}
|
||||
let home = ProcessInfo.processInfo.environment["HOME"].flatMap { value -> String? in
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
} ?? NSHomeDirectory()
|
||||
return URL(fileURLWithPath: home, isDirectory: true)
|
||||
.appendingPathComponent(configDir, isDirectory: true)
|
||||
.path
|
||||
}
|
||||
|
||||
init(name: String, displayName: String, statusKey: String,
|
||||
configDir: String, configFile: String, configDirEnvOverride: String? = nil,
|
||||
binaryName: String? = nil,
|
||||
sessionStoreSuffix: String, disableEnvVar: String, hookMarker: String,
|
||||
format: HookFormat, events: [HookEvent],
|
||||
aliases: Set<String> = [],
|
||||
feedHookEvents: [String] = [],
|
||||
postInstallAction: PostInstallAction? = nil) {
|
||||
self.name = name; self.displayName = displayName; self.statusKey = statusKey
|
||||
self.configDir = configDir; self.configFile = configFile
|
||||
self.configDirEnvOverride = configDirEnvOverride
|
||||
self.binaryName = binaryName ?? name
|
||||
self.sessionStoreSuffix = sessionStoreSuffix; self.disableEnvVar = disableEnvVar
|
||||
self.hookMarker = hookMarker; self.format = format; self.events = events
|
||||
self.aliases = Set(aliases.compactMap { alias in
|
||||
let normalized = alias.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized.isEmpty ? nil : normalized
|
||||
})
|
||||
self.feedHookEvents = feedHookEvents
|
||||
self.postInstallAction = postInstallAction
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentHookAction {
|
||||
case sessionStart, promptSubmit, stop, sessionEnd, noop
|
||||
}
|
||||
|
||||
static let subcommandActions: [String: AgentHookAction] = [
|
||||
"session-start": .sessionStart,
|
||||
"prompt-submit": .promptSubmit,
|
||||
"stop": .stop,
|
||||
"agent-response": .stop,
|
||||
"shell-exec": .promptSubmit,
|
||||
"shell-done": .noop,
|
||||
"session-end": .sessionEnd,
|
||||
]
|
||||
|
||||
// MARK: Agent definitions
|
||||
|
||||
static let agentDefs: [AgentHookDef] = [
|
||||
AgentHookDef(
|
||||
name: "codex", displayName: "Codex", statusKey: "codex",
|
||||
configDir: ".codex", configFile: "hooks.json", configDirEnvOverride: "CODEX_HOME",
|
||||
sessionStoreSuffix: "codex", disableEnvVar: "CMUX_CODEX_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks codex", format: .nested(timeoutMs: 5000),
|
||||
events: [
|
||||
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
|
||||
.init(agentEvent: "UserPromptSubmit", cmuxSubcommand: "prompt-submit"),
|
||||
.init(agentEvent: "Stop", cmuxSubcommand: "stop"),
|
||||
],
|
||||
feedHookEvents: ["PreToolUse", "PermissionRequest"],
|
||||
postInstallAction: .codexConfigToml
|
||||
),
|
||||
AgentHookDef(
|
||||
name: "opencode", displayName: "OpenCode", statusKey: "opencode",
|
||||
configDir: ".config/opencode", configFile: "plugins/cmux-session.js", configDirEnvOverride: "OPENCODE_CONFIG_DIR",
|
||||
sessionStoreSuffix: "opencode", disableEnvVar: "CMUX_OPENCODE_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks opencode", format: .flat,
|
||||
events: []
|
||||
),
|
||||
AgentHookDef(
|
||||
name: "pi", displayName: "Pi", statusKey: "pi",
|
||||
configDir: ".pi/agent", configFile: "extensions/cmux-session.ts", configDirEnvOverride: "PI_CODING_AGENT_DIR",
|
||||
sessionStoreSuffix: "pi", disableEnvVar: "CMUX_PI_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks pi", format: .flat,
|
||||
events: []
|
||||
),
|
||||
AgentHookDef(
|
||||
name: "amp", displayName: "Amp", statusKey: "amp",
|
||||
configDir: ".config/amp", configFile: "plugins/cmux-session.ts",
|
||||
sessionStoreSuffix: "amp", disableEnvVar: "CMUX_AMP_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks amp", format: .flat,
|
||||
events: []
|
||||
),
|
||||
AgentHookDef(
|
||||
name: "cursor", displayName: "Cursor", statusKey: "cursor",
|
||||
configDir: ".cursor", configFile: "hooks.json", binaryName: "cursor-agent",
|
||||
sessionStoreSuffix: "cursor", disableEnvVar: "CMUX_CURSOR_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks cursor", format: .flat,
|
||||
events: [
|
||||
.init(agentEvent: "beforeSubmitPrompt", cmuxSubcommand: "prompt-submit"),
|
||||
.init(agentEvent: "stop", cmuxSubcommand: "stop"),
|
||||
.init(agentEvent: "afterAgentResponse", cmuxSubcommand: "agent-response"),
|
||||
.init(agentEvent: "beforeShellExecution", cmuxSubcommand: "shell-exec"),
|
||||
.init(agentEvent: "afterShellExecution", cmuxSubcommand: "shell-done"),
|
||||
],
|
||||
feedHookEvents: ["beforeShellExecution"]
|
||||
),
|
||||
AgentHookDef(
|
||||
name: "gemini", displayName: "Gemini", statusKey: "gemini",
|
||||
configDir: ".gemini", configFile: "settings.json",
|
||||
sessionStoreSuffix: "gemini", disableEnvVar: "CMUX_GEMINI_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks gemini", format: .nested(timeoutMs: 10000),
|
||||
events: [
|
||||
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
|
||||
.init(agentEvent: "BeforeAgent", cmuxSubcommand: "prompt-submit"),
|
||||
.init(agentEvent: "AfterAgent", cmuxSubcommand: "stop"),
|
||||
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
|
||||
],
|
||||
feedHookEvents: ["PreToolUse"]
|
||||
),
|
||||
AgentHookDef(
|
||||
name: "rovodev", displayName: "Rovo Dev", statusKey: "rovodev",
|
||||
configDir: ".rovodev", configFile: "config.yml", binaryName: "acli",
|
||||
sessionStoreSuffix: "rovodev", disableEnvVar: "CMUX_ROVODEV_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks rovodev", format: .rovoDevYAML,
|
||||
events: [
|
||||
.init(agentEvent: "on_complete", cmuxSubcommand: "stop"),
|
||||
.init(agentEvent: "on_error", cmuxSubcommand: "stop"),
|
||||
.init(agentEvent: "on_tool_permission", cmuxSubcommand: "prompt-submit"),
|
||||
],
|
||||
aliases: ["rovo"]
|
||||
),
|
||||
AgentHookDef(
|
||||
name: "hermes-agent", displayName: "Hermes Agent", statusKey: "hermes-agent",
|
||||
configDir: ".hermes", configFile: "config.yaml", configDirEnvOverride: "HERMES_HOME",
|
||||
binaryName: "hermes",
|
||||
sessionStoreSuffix: "hermes-agent", disableEnvVar: "CMUX_HERMES_AGENT_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks hermes-agent", format: .hermesAgentYAML,
|
||||
events: [
|
||||
.init(agentEvent: "on_session_start", cmuxSubcommand: "session-start"),
|
||||
.init(agentEvent: "pre_llm_call", cmuxSubcommand: "prompt-submit"),
|
||||
.init(agentEvent: "post_llm_call", cmuxSubcommand: "agent-response"),
|
||||
.init(agentEvent: "on_session_end", cmuxSubcommand: "session-end"),
|
||||
.init(agentEvent: "on_session_finalize", cmuxSubcommand: "session-end"),
|
||||
.init(agentEvent: "on_session_reset", cmuxSubcommand: "session-start"),
|
||||
],
|
||||
feedHookEvents: ["pre_tool_call", "post_tool_call", "pre_approval_request", "post_approval_response"]
|
||||
),
|
||||
AgentHookDef(
|
||||
name: "copilot", displayName: "Copilot", statusKey: "copilot",
|
||||
configDir: ".copilot", configFile: "config.json", configDirEnvOverride: "COPILOT_HOME",
|
||||
sessionStoreSuffix: "copilot", disableEnvVar: "CMUX_COPILOT_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks copilot", format: .nested(timeoutMs: 5000),
|
||||
events: [
|
||||
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
|
||||
.init(agentEvent: "Stop", cmuxSubcommand: "stop"),
|
||||
.init(agentEvent: "Notification", cmuxSubcommand: "stop"),
|
||||
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
|
||||
],
|
||||
feedHookEvents: ["PreToolUse"]
|
||||
),
|
||||
AgentHookDef(
|
||||
name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy",
|
||||
configDir: ".codebuddy", configFile: "settings.json", configDirEnvOverride: "CODEBUDDY_CONFIG_DIR",
|
||||
sessionStoreSuffix: "codebuddy", disableEnvVar: "CMUX_CODEBUDDY_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks codebuddy", format: .nested(timeoutMs: 5000),
|
||||
events: [
|
||||
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
|
||||
.init(agentEvent: "Stop", cmuxSubcommand: "stop"),
|
||||
.init(agentEvent: "Notification", cmuxSubcommand: "stop"),
|
||||
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
|
||||
],
|
||||
feedHookEvents: ["PreToolUse"]
|
||||
),
|
||||
AgentHookDef(
|
||||
name: "factory", displayName: "Factory", statusKey: "factory",
|
||||
configDir: ".factory", configFile: "settings.json", binaryName: "droid",
|
||||
sessionStoreSuffix: "factory", disableEnvVar: "CMUX_FACTORY_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks factory", format: .nested(timeoutMs: 5000),
|
||||
events: [
|
||||
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
|
||||
.init(agentEvent: "Stop", cmuxSubcommand: "stop"),
|
||||
.init(agentEvent: "Notification", cmuxSubcommand: "stop"),
|
||||
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
|
||||
],
|
||||
feedHookEvents: ["PreToolUse"]
|
||||
),
|
||||
AgentHookDef(
|
||||
name: "qoder", displayName: "Qoder", statusKey: "qoder",
|
||||
configDir: ".qoder", configFile: "settings.json", configDirEnvOverride: "QODER_CONFIG_DIR", binaryName: "qodercli",
|
||||
sessionStoreSuffix: "qoder", disableEnvVar: "CMUX_QODER_HOOKS_DISABLED",
|
||||
hookMarker: "cmux hooks qoder", format: .nested(timeoutMs: 5000),
|
||||
events: [
|
||||
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
|
||||
.init(agentEvent: "Stop", cmuxSubcommand: "stop"),
|
||||
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
|
||||
],
|
||||
feedHookEvents: ["PreToolUse"]
|
||||
),
|
||||
]
|
||||
|
||||
static func agentDef(named name: String) -> AgentHookDef? {
|
||||
let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return agentDefs.first { $0.name == normalized || $0.aliases.contains(normalized) }
|
||||
}
|
||||
|
||||
static func hookMarkers(for def: AgentHookDef) -> [String] {
|
||||
var markers = [def.hookMarker]
|
||||
if def.name == "codex" {
|
||||
markers.append("cmux codex-hook")
|
||||
}
|
||||
return markers
|
||||
}
|
||||
|
||||
/// Marker substrings used when removing / upgrading our own Feed bridge
|
||||
/// entries on reinstall or uninstall.
|
||||
static func feedHookMarkers(for def: AgentHookDef) -> [String] {
|
||||
var markers = ["cmux hooks feed --source"]
|
||||
if def.name == "codex" {
|
||||
markers.append("cmux feed-hook --source")
|
||||
}
|
||||
return markers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
|
||||
extension CMUXCLI {
|
||||
private static let ampExtensionMarker = "cmux-amp-session-extension-marker"
|
||||
private static let ampExtensionFilename = "cmux-session.ts"
|
||||
private static let ampExtensionSource = #"""
|
||||
// cmux-amp-session-extension-marker v1
|
||||
// Bridges Amp session lifecycle events into cmux's restorable session store.
|
||||
// Installed by `cmux hooks amp install` or `cmux hooks setup`.
|
||||
// DO NOT EDIT MANUALLY. cmux upgrades this file in place.
|
||||
// @i-know-the-amp-plugin-api-is-wip-and-very-experimental-right-now
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type {
|
||||
PluginAPI,
|
||||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
SessionStartEvent,
|
||||
} from "@ampcode/plugin";
|
||||
|
||||
function firstString(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveExecutable(name: string): string {
|
||||
const pathEnv = process.env.PATH || "";
|
||||
for (const dir of pathEnv.split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
const candidate = path.join(dir, name);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
return candidate;
|
||||
} catch (_) {}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function looksLikeAmpExecutable(value: string): boolean {
|
||||
return path.basename(value).toLowerCase() === "amp";
|
||||
}
|
||||
|
||||
function looksLikeAmpScript(value: string): boolean {
|
||||
const normalized = value.replaceAll("\\", "/");
|
||||
const base = path.basename(normalized).toLowerCase();
|
||||
return (
|
||||
normalized.includes("/@ampcode/") ||
|
||||
(base === "cli.js" && normalized.includes("amp"))
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeJavaScriptRuntime(value: string): boolean {
|
||||
const base = path.basename(value).toLowerCase();
|
||||
return base === "node" || base === "bun" || base === "deno" || base === "tsx" || base === "ts-node";
|
||||
}
|
||||
|
||||
function normalizedLaunchArgv(): string[] {
|
||||
const raw = Array.isArray(process.argv) ? process.argv.map((value) => String(value)) : [];
|
||||
if (raw.length === 0) return [resolveExecutable("amp")];
|
||||
if (looksLikeAmpExecutable(raw[0])) return raw;
|
||||
if (raw.length > 1 && (looksLikeAmpScript(raw[1]) || looksLikeJavaScriptRuntime(raw[0]))) {
|
||||
return [resolveExecutable("amp"), ...raw.slice(2)];
|
||||
}
|
||||
return [resolveExecutable("amp")];
|
||||
}
|
||||
|
||||
function base64NulSeparated(values: string[]): string {
|
||||
const bytes: Buffer[] = [];
|
||||
for (const value of values) {
|
||||
bytes.push(Buffer.from(String(value), "utf8"));
|
||||
bytes.push(Buffer.from([0]));
|
||||
}
|
||||
return Buffer.concat(bytes).toString("base64");
|
||||
}
|
||||
|
||||
function hookEnvironment(cwd: string): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
delete env.AMP_API_KEY;
|
||||
if (!env.CMUX_AGENT_LAUNCH_ARGV_B64) {
|
||||
const argv = normalizedLaunchArgv();
|
||||
env.CMUX_AGENT_LAUNCH_KIND = "amp";
|
||||
env.CMUX_AGENT_LAUNCH_EXECUTABLE = argv[0] || resolveExecutable("amp");
|
||||
env.CMUX_AGENT_LAUNCH_ARGV_B64 = base64NulSeparated(argv);
|
||||
env.CMUX_AGENT_LAUNCH_CWD = cwd || process.cwd();
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function eventName(subcommand: string): string {
|
||||
switch (subcommand) {
|
||||
case "session-start":
|
||||
return "SessionStart";
|
||||
case "prompt-submit":
|
||||
return "UserPromptSubmit";
|
||||
case "stop":
|
||||
return "Stop";
|
||||
default:
|
||||
return subcommand;
|
||||
}
|
||||
}
|
||||
|
||||
function sendHook(
|
||||
subcommand: string,
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): void {
|
||||
if (process.env.CMUX_AMP_HOOKS_DISABLED === "1") return;
|
||||
if (!process.env.CMUX_SURFACE_ID) return;
|
||||
if (!sessionId) return;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
session_id: sessionId,
|
||||
cwd,
|
||||
hook_event_name: eventName(subcommand),
|
||||
event: eventName(subcommand),
|
||||
...extra,
|
||||
};
|
||||
const cmux = process.env.CMUX_AMP_CMUX_BIN || "cmux";
|
||||
try {
|
||||
const child = spawn(cmux, ["hooks", "amp", subcommand], {
|
||||
env: hookEnvironment(cwd),
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
detached: true,
|
||||
});
|
||||
child.on("error", () => {});
|
||||
child.stdin.on("error", () => {});
|
||||
child.stdin.end(JSON.stringify(payload));
|
||||
child.unref();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
type AmpThreadContext = { thread?: { id?: string } };
|
||||
|
||||
function threadIdFrom(event: { thread?: { id?: string } } | undefined, ctx?: AmpThreadContext): string | null {
|
||||
return firstString(event?.thread?.id, ctx?.thread?.id);
|
||||
}
|
||||
|
||||
export default function (amp: PluginAPI) {
|
||||
const cwdFromEnv = (): string =>
|
||||
firstString(process.env.PWD, process.cwd()) || process.cwd();
|
||||
|
||||
amp.on("session.start", async (event: SessionStartEvent, ctx) => {
|
||||
const sessionId = threadIdFrom(event, ctx);
|
||||
if (!sessionId) return;
|
||||
sendHook("session-start", sessionId, cwdFromEnv());
|
||||
});
|
||||
|
||||
amp.on("agent.start", async (event: AgentStartEvent, ctx) => {
|
||||
const sessionId = threadIdFrom(event, ctx);
|
||||
if (!sessionId) return;
|
||||
sendHook("prompt-submit", sessionId, cwdFromEnv());
|
||||
});
|
||||
|
||||
amp.on("agent.end", async (event: AgentEndEvent, ctx) => {
|
||||
const sessionId = threadIdFrom(event, ctx);
|
||||
if (!sessionId) return;
|
||||
sendHook("stop", sessionId, cwdFromEnv());
|
||||
});
|
||||
}
|
||||
"""#
|
||||
|
||||
private func ampExtensionURL(for def: AgentHookDef) -> URL {
|
||||
URL(fileURLWithPath: def.resolvedConfigDir(), isDirectory: true)
|
||||
.appendingPathComponent("plugins", isDirectory: true)
|
||||
.appendingPathComponent(Self.ampExtensionFilename, isDirectory: false)
|
||||
}
|
||||
|
||||
func installAmpExtensionHooks(_ def: AgentHookDef) throws {
|
||||
let extensionURL = ampExtensionURL(for: def)
|
||||
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
|
||||
|| ProcessInfo.processInfo.arguments.contains("-y")
|
||||
let existing = (try? String(contentsOf: extensionURL, encoding: .utf8)) ?? ""
|
||||
if existing == Self.ampExtensionSource {
|
||||
print("Amp hooks already up to date at \(extensionURL.path)")
|
||||
return
|
||||
}
|
||||
if !existing.isEmpty, !existing.contains(Self.ampExtensionMarker) {
|
||||
throw CLIError(message: "\(extensionURL.path) exists and is not a cmux plugin; leaving it alone")
|
||||
}
|
||||
if !skipConfirm {
|
||||
Self.printInstallPreview(
|
||||
path: extensionURL.path,
|
||||
oldContent: existing,
|
||||
newContent: Self.ampExtensionSource,
|
||||
fallbackContent: Self.ampExtensionSource
|
||||
)
|
||||
print("\nProceed? [y/N] ", terminator: "")
|
||||
guard readLine()?.lowercased().hasPrefix("y") == true else {
|
||||
print("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: extensionURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Self.ampExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8)
|
||||
print("Amp hooks installed at \(extensionURL.path)")
|
||||
}
|
||||
|
||||
func uninstallAmpExtensionHooks(_ def: AgentHookDef) throws {
|
||||
let extensionURL = ampExtensionURL(for: def)
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: extensionURL.path) else {
|
||||
print("No Amp cmux plugin found at \(extensionURL.path)")
|
||||
return
|
||||
}
|
||||
let existing = (try? String(contentsOf: extensionURL, encoding: .utf8)) ?? ""
|
||||
guard existing.contains(Self.ampExtensionMarker) else {
|
||||
print("Refusing to remove \(extensionURL.path): missing cmux marker")
|
||||
return
|
||||
}
|
||||
try fm.removeItem(at: extensionURL)
|
||||
print("Removed Amp cmux plugin from \(extensionURL.path)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import Foundation
|
||||
|
||||
extension CMUXCLI {
|
||||
private static let cmuxCodexHooksFeatureBegin =
|
||||
"# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df begin"
|
||||
private static let cmuxCodexHooksFeatureEnd =
|
||||
"# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df end"
|
||||
private static let cmuxCodexHooksFeaturePreviousLinePrefix =
|
||||
"# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df previous line: "
|
||||
private static let legacyCmuxCodexHooksFeatureBegin = "# cmux hooks codex feature begin"
|
||||
private static let legacyCmuxCodexHooksFeatureEnd = "# cmux hooks codex feature end"
|
||||
private static let legacyCmuxCodexHooksFeaturePreviousLinePrefix = "# cmux hooks codex feature previous line: "
|
||||
|
||||
static func codexConfigTomlInstallingHooksFeature(in existingContent: String) -> String {
|
||||
var lines = tomlLines(from: existingContent)
|
||||
removeCmuxCodexHooksFeatureBlock(from: &lines)
|
||||
lines.removeAll { tomlLineDefinesKey("codex_hooks", line: $0) }
|
||||
lines.removeAll { tomlLineDefinesDottedFeaturesKey("codex_hooks", line: $0) }
|
||||
|
||||
let insertedLines = [
|
||||
cmuxCodexHooksFeatureBegin,
|
||||
"hooks = true",
|
||||
cmuxCodexHooksFeatureEnd,
|
||||
]
|
||||
let insertedDottedLines = [
|
||||
cmuxCodexHooksFeatureBegin,
|
||||
"features.hooks = true",
|
||||
cmuxCodexHooksFeatureEnd,
|
||||
]
|
||||
|
||||
if let featuresStart = lines.firstIndex(where: { tomlLineIsTable("features", line: $0) }) {
|
||||
let featuresEnd = tomlTableEndIndex(in: lines, after: featuresStart)
|
||||
if featuresStart + 1 < featuresEnd,
|
||||
let hooksIndex = (featuresStart + 1..<featuresEnd)
|
||||
.first(where: { tomlLineDefinesKey("hooks", line: lines[$0]) })
|
||||
{
|
||||
if !tomlLineDefinesTrueKey("hooks", line: lines[hooksIndex]) {
|
||||
let previousLine = lines[hooksIndex]
|
||||
lines.replaceSubrange(
|
||||
hooksIndex...hooksIndex,
|
||||
with: codexHooksFeatureLines(settingLine: "hooks = true", previousLine: previousLine)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
lines.insert(contentsOf: insertedLines, at: featuresStart + 1)
|
||||
}
|
||||
} else if let dottedHooksIndex = lines.firstIndex(where: { tomlLineDefinesDottedFeaturesKey("hooks", line: $0) }) {
|
||||
if !tomlLineDefinesDottedFeaturesTrueKey("hooks", line: lines[dottedHooksIndex]) {
|
||||
let previousLine = lines[dottedHooksIndex]
|
||||
lines.replaceSubrange(
|
||||
dottedHooksIndex...dottedHooksIndex,
|
||||
with: codexHooksFeatureLines(settingLine: "features.hooks = true", previousLine: previousLine)
|
||||
)
|
||||
}
|
||||
} else if let firstDottedFeaturesIndex = lines.firstIndex(where: { tomlLineDefinesAnyDottedFeaturesKey($0) }) {
|
||||
lines.insert(contentsOf: insertedDottedLines, at: firstDottedFeaturesIndex)
|
||||
} else {
|
||||
if !lines.isEmpty, lines.last?.isEmpty == false {
|
||||
lines.append("")
|
||||
}
|
||||
lines.append("[features]")
|
||||
lines.append(contentsOf: insertedLines)
|
||||
}
|
||||
|
||||
return tomlContent(from: lines)
|
||||
}
|
||||
|
||||
private static func codexHooksFeatureLines(settingLine: String, previousLine: String? = nil) -> [String] {
|
||||
var lines = [cmuxCodexHooksFeatureBegin]
|
||||
if let previousLine {
|
||||
lines.append(cmuxCodexHooksFeaturePreviousLinePrefix + previousLine)
|
||||
}
|
||||
lines.append(settingLine)
|
||||
lines.append(cmuxCodexHooksFeatureEnd)
|
||||
return lines
|
||||
}
|
||||
|
||||
static func codexConfigTomlUninstallingHooksFeature(from existingContent: String) -> String {
|
||||
var lines = tomlLines(from: existingContent)
|
||||
removeCmuxCodexHooksFeatureBlock(from: &lines)
|
||||
lines.removeAll { tomlLineDefinesKey("codex_hooks", line: $0) }
|
||||
lines.removeAll { tomlLineDefinesDottedFeaturesKey("codex_hooks", line: $0) }
|
||||
removeEmptyFeaturesTable(from: &lines)
|
||||
return tomlContent(from: lines)
|
||||
}
|
||||
|
||||
private static func tomlLines(from content: String) -> [String] {
|
||||
guard !content.isEmpty else { return [] }
|
||||
var lines = content.components(separatedBy: "\n")
|
||||
if content.hasSuffix("\n"), lines.last == "" {
|
||||
lines.removeLast()
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func tomlContent(from lines: [String]) -> String {
|
||||
guard !lines.isEmpty else { return "" }
|
||||
return lines.joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
private static func tomlLineDefinesKey(_ key: String, line: String) -> Bool {
|
||||
let escapedKey = NSRegularExpression.escapedPattern(for: key)
|
||||
return line.range(
|
||||
of: #"^\s*"# + escapedKey + #"\s*="#,
|
||||
options: .regularExpression
|
||||
) != nil
|
||||
}
|
||||
|
||||
private static func tomlLineDefinesTrueKey(_ key: String, line: String) -> Bool {
|
||||
let escapedKey = NSRegularExpression.escapedPattern(for: key)
|
||||
return line.range(
|
||||
of: #"^\s*"# + escapedKey + #"\s*=\s*true\s*(#.*)?$"#,
|
||||
options: .regularExpression
|
||||
) != nil
|
||||
}
|
||||
|
||||
private static func tomlLineDefinesDottedFeaturesKey(_ key: String, line: String) -> Bool {
|
||||
let escapedKey = NSRegularExpression.escapedPattern(for: key)
|
||||
return line.range(
|
||||
of: #"^\s*features\s*\.\s*"# + escapedKey + #"\s*="#,
|
||||
options: .regularExpression
|
||||
) != nil
|
||||
}
|
||||
|
||||
private static func tomlLineDefinesDottedFeaturesTrueKey(_ key: String, line: String) -> Bool {
|
||||
let escapedKey = NSRegularExpression.escapedPattern(for: key)
|
||||
return line.range(
|
||||
of: #"^\s*features\s*\.\s*"# + escapedKey + #"\s*=\s*true\s*(#.*)?$"#,
|
||||
options: .regularExpression
|
||||
) != nil
|
||||
}
|
||||
|
||||
private static func tomlLineDefinesAnyDottedFeaturesKey(_ line: String) -> Bool {
|
||||
line.range(
|
||||
of: #"^\s*features\s*\.\s*[^=\s]+\s*="#,
|
||||
options: .regularExpression
|
||||
) != nil
|
||||
}
|
||||
|
||||
private static func tomlLineIsTable(_ name: String, line: String) -> Bool {
|
||||
let escapedName = NSRegularExpression.escapedPattern(for: name)
|
||||
return line.range(
|
||||
of: #"^\s*\[\s*"# + escapedName + #"\s*\]\s*(#.*)?$"#,
|
||||
options: .regularExpression
|
||||
) != nil
|
||||
}
|
||||
|
||||
private static func tomlLineIsAnyTableHeader(_ line: String) -> Bool {
|
||||
let tomlKey = "(?:[A-Za-z0-9_-]+|\"[^\"\\n]*\"|'[^'\\n]*')"
|
||||
let tomlKeyPath = tomlKey + "(?:\\s*\\.\\s*" + tomlKey + ")*"
|
||||
let pattern = "^\\s*(?:\\[\\s*" + tomlKeyPath + "\\s*\\]|\\[\\[\\s*" + tomlKeyPath
|
||||
+ "\\s*\\]\\])\\s*(#.*)?$"
|
||||
return line.range(
|
||||
of: pattern,
|
||||
options: .regularExpression
|
||||
) != nil
|
||||
}
|
||||
|
||||
private static func tomlTableEndIndex(in lines: [String], after tableStart: Int) -> Int {
|
||||
var index = tableStart + 1
|
||||
while index < lines.count {
|
||||
if tomlLineIsAnyTableHeader(lines[index]) {
|
||||
return index
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return lines.count
|
||||
}
|
||||
|
||||
private static func removeCmuxCodexHooksFeatureBlock(from lines: inout [String]) {
|
||||
var index = 0
|
||||
while index < lines.count {
|
||||
guard tomlLineIsCodexHooksFeatureBegin(lines[index]) else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if let endIndex = lines[index...].firstIndex(where: {
|
||||
tomlLineIsCodexHooksFeatureEnd($0)
|
||||
}) {
|
||||
let previousLines = lines[index...endIndex].compactMap { line -> String? in
|
||||
tomlCodexHooksFeaturePreviousLine(from: line)
|
||||
}
|
||||
lines.replaceSubrange(index...endIndex, with: previousLines)
|
||||
} else {
|
||||
var blockEnd = index + 1
|
||||
var previousLines: [String] = []
|
||||
if blockEnd < lines.count,
|
||||
let previousLine = tomlCodexHooksFeaturePreviousLine(from: lines[blockEnd])
|
||||
{
|
||||
previousLines.append(previousLine)
|
||||
blockEnd += 1
|
||||
}
|
||||
if blockEnd < lines.count, tomlLineIsCodexHooksFeatureSetting(lines[blockEnd]) {
|
||||
blockEnd += 1
|
||||
}
|
||||
lines.replaceSubrange(index..<blockEnd, with: previousLines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func tomlLineIsCodexHooksFeatureBegin(_ line: String) -> Bool {
|
||||
line == cmuxCodexHooksFeatureBegin || line == legacyCmuxCodexHooksFeatureBegin
|
||||
}
|
||||
|
||||
private static func tomlLineIsCodexHooksFeatureEnd(_ line: String) -> Bool {
|
||||
line == cmuxCodexHooksFeatureEnd || line == legacyCmuxCodexHooksFeatureEnd
|
||||
}
|
||||
|
||||
private static func tomlCodexHooksFeaturePreviousLine(from line: String) -> String? {
|
||||
if line.hasPrefix(cmuxCodexHooksFeaturePreviousLinePrefix) {
|
||||
return String(line.dropFirst(cmuxCodexHooksFeaturePreviousLinePrefix.count))
|
||||
}
|
||||
if line.hasPrefix(legacyCmuxCodexHooksFeaturePreviousLinePrefix) {
|
||||
return String(line.dropFirst(legacyCmuxCodexHooksFeaturePreviousLinePrefix.count))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func tomlLineIsCodexHooksFeatureSetting(_ line: String) -> Bool {
|
||||
tomlLineDefinesTrueKey("hooks", line: line)
|
||||
|| tomlLineDefinesDottedFeaturesTrueKey("hooks", line: line)
|
||||
}
|
||||
|
||||
private static func removeEmptyFeaturesTable(from lines: inout [String]) {
|
||||
guard let featuresStart = lines.firstIndex(where: { tomlLineIsTable("features", line: $0) }) else {
|
||||
return
|
||||
}
|
||||
let featuresEnd = tomlTableEndIndex(in: lines, after: featuresStart)
|
||||
let bodyRange = featuresStart + 1..<featuresEnd
|
||||
let hasContent = bodyRange.contains { index in
|
||||
let trimmed = lines[index].trimmingCharacters(in: .whitespaces)
|
||||
return !trimmed.isEmpty && !trimmed.hasPrefix("#")
|
||||
}
|
||||
if !hasContent {
|
||||
lines.removeSubrange(featuresStart..<featuresEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,16 +84,19 @@ extension CMUXCLI {
|
||||
DocsReference(
|
||||
topic: "agents",
|
||||
aliases: ["integrations", "agent-integrations"],
|
||||
summary: "Codex, Claude Code, OpenCode, and agent workflow integrations.",
|
||||
summary: "Agent hook integrations, Feed approvals, notifications, and session restore.",
|
||||
webURL: "https://cmux.com/docs/agent-integrations/oh-my-codex",
|
||||
rawResources: [
|
||||
DocsResource(label: "agent hook docs", url: "https://raw.githubusercontent.com/manaflow-ai/cmux/main/docs/agent-hooks.md"),
|
||||
DocsResource(label: "feed docs", url: "https://raw.githubusercontent.com/manaflow-ai/cmux/main/docs/feed.md"),
|
||||
DocsResource(label: "notifications docs", url: "https://raw.githubusercontent.com/manaflow-ai/cmux/main/docs/notifications.md"),
|
||||
],
|
||||
commands: [
|
||||
"cmux codex install-hooks",
|
||||
"cmux hooks opencode install",
|
||||
"cmux hooks setup",
|
||||
"cmux hooks setup <agent>",
|
||||
"cmux hooks hermes-agent install",
|
||||
"cmux hooks hermes-agent uninstall",
|
||||
"cmux hooks <agent> uninstall",
|
||||
]
|
||||
),
|
||||
DocsReference(
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import CoreFoundation
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
private struct EventStreamLimitReached: Error {}
|
||||
|
||||
extension CMUXCLI {
|
||||
private struct EventsCommandOptions {
|
||||
var afterSeq: Int64?
|
||||
var cursorFile: String?
|
||||
var names: [String] = []
|
||||
var categories: [String] = []
|
||||
var reconnect = false
|
||||
var limit: Int?
|
||||
var printAck = true
|
||||
var printHeartbeats = true
|
||||
}
|
||||
|
||||
func runEventsCommand(
|
||||
commandArgs: [String],
|
||||
socketPath: String,
|
||||
explicitPassword: String?
|
||||
) throws {
|
||||
var options = try parseEventsOptions(commandArgs)
|
||||
if options.afterSeq == nil, let cursorFile = options.cursorFile {
|
||||
options.afterSeq = try readEventCursor(from: cursorFile)
|
||||
}
|
||||
|
||||
var lastSeq = options.afterSeq
|
||||
var emittedEvents = 0
|
||||
|
||||
while true {
|
||||
let client = SocketClient(path: socketPath)
|
||||
do {
|
||||
try client.connect()
|
||||
try authenticateClientIfNeeded(
|
||||
client,
|
||||
explicitPassword: explicitPassword,
|
||||
socketPath: socketPath
|
||||
)
|
||||
|
||||
var params: [String: Any] = [
|
||||
"include_heartbeats": true
|
||||
]
|
||||
if let lastSeq {
|
||||
params["after_seq"] = NSNumber(value: lastSeq)
|
||||
}
|
||||
if !options.names.isEmpty {
|
||||
params["names"] = options.names
|
||||
}
|
||||
if !options.categories.isEmpty {
|
||||
params["categories"] = options.categories
|
||||
}
|
||||
|
||||
try client.streamV2(method: "events.stream", params: params) { line in
|
||||
guard !line.isEmpty else { return }
|
||||
let frame = try parseEventStreamFrame(line)
|
||||
let type = frame["type"] as? String ?? ""
|
||||
|
||||
let eventSequence: Int64?
|
||||
if type == "event" {
|
||||
guard let seq = int64Value(frame["seq"]) else {
|
||||
throw CLIError(message: "Invalid event stream frame: event missing numeric seq")
|
||||
}
|
||||
eventSequence = seq
|
||||
} else {
|
||||
eventSequence = nil
|
||||
}
|
||||
|
||||
if type == "ack", !options.printAck {
|
||||
return
|
||||
}
|
||||
if type == "heartbeat", !options.printHeartbeats {
|
||||
return
|
||||
}
|
||||
|
||||
print(line)
|
||||
fflush(stdout)
|
||||
|
||||
if let eventSequence {
|
||||
if let cursorFile = options.cursorFile {
|
||||
try writeEventCursor(eventSequence, to: cursorFile)
|
||||
}
|
||||
lastSeq = eventSequence
|
||||
emittedEvents += 1
|
||||
if let limit = options.limit, emittedEvents >= limit {
|
||||
throw EventStreamLimitReached()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch is EventStreamLimitReached {
|
||||
client.close()
|
||||
return
|
||||
} catch {
|
||||
client.close()
|
||||
guard options.reconnect, isTransientEventStreamError(error) else {
|
||||
throw error
|
||||
}
|
||||
waitBeforeReconnectingEventStream()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isTransientEventStreamError(_ error: Error) -> Bool {
|
||||
if let cliError = error as? CLIError {
|
||||
let message = cliError.message.lowercased()
|
||||
let transientMarkers = [
|
||||
"socket not found",
|
||||
"failed to connect",
|
||||
"event stream closed",
|
||||
"event stream socket read error",
|
||||
"timed out waiting for event stream frame",
|
||||
"stream request timed out",
|
||||
"failed to write stream request",
|
||||
"broken pipe",
|
||||
"connection reset",
|
||||
"connection refused",
|
||||
"errno 32",
|
||||
"errno 35",
|
||||
"errno 54",
|
||||
"errno 57",
|
||||
"errno 60",
|
||||
"errno 61"
|
||||
]
|
||||
return transientMarkers.contains { message.contains($0) }
|
||||
}
|
||||
|
||||
let description = String(describing: error).lowercased()
|
||||
return description.contains("connection reset")
|
||||
|| description.contains("connection refused")
|
||||
|| description.contains("broken pipe")
|
||||
|| description.contains("timed out")
|
||||
}
|
||||
|
||||
func waitBeforeReconnectingEventStream() {
|
||||
let deadline = Date(timeIntervalSinceNow: 1.0)
|
||||
var didFire = false
|
||||
let timer = Timer(timeInterval: 1.0, repeats: false) { _ in
|
||||
didFire = true
|
||||
}
|
||||
RunLoop.current.add(timer, forMode: .default)
|
||||
while !didFire, RunLoop.current.run(mode: .default, before: deadline) {}
|
||||
timer.invalidate()
|
||||
}
|
||||
|
||||
private func parseEventsOptions(_ args: [String]) throws -> EventsCommandOptions {
|
||||
var options = EventsCommandOptions()
|
||||
var index = 0
|
||||
while index < args.count {
|
||||
let arg = args[index]
|
||||
func requireValue() throws -> String {
|
||||
guard index + 1 < args.count else {
|
||||
throw CLIError(message: "\(arg) requires a value")
|
||||
}
|
||||
index += 1
|
||||
return args[index]
|
||||
}
|
||||
|
||||
switch arg {
|
||||
case "--after", "--after-seq":
|
||||
let raw = try requireValue()
|
||||
guard let seq = Int64(raw), seq >= 0 else {
|
||||
throw CLIError(message: "\(arg) must be a non-negative integer")
|
||||
}
|
||||
options.afterSeq = seq
|
||||
case "--cursor-file":
|
||||
options.cursorFile = try requireValue()
|
||||
case "--name":
|
||||
options.names.append(try requireValue())
|
||||
case "--category":
|
||||
options.categories.append(try requireValue())
|
||||
case "--reconnect":
|
||||
options.reconnect = true
|
||||
case "--limit":
|
||||
let raw = try requireValue()
|
||||
guard let limit = Int(raw), limit > 0 else {
|
||||
throw CLIError(message: "--limit must be greater than 0")
|
||||
}
|
||||
options.limit = limit
|
||||
case "--no-ack":
|
||||
options.printAck = false
|
||||
case "--no-heartbeat", "--no-heartbeats":
|
||||
options.printHeartbeats = false
|
||||
default:
|
||||
throw CLIError(message: "Unknown events option: \(arg)")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
private func parseEventStreamFrame(_ line: String) throws -> [String: Any] {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw CLIError(message: "Invalid event stream frame: \(line)")
|
||||
}
|
||||
if let ok = object["ok"] as? Bool, ok == false {
|
||||
let error = object["error"] as? [String: Any]
|
||||
let message = error?["message"] as? String ?? "event stream error"
|
||||
throw CLIError(message: message)
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
private func readEventCursor(from path: String) throws -> Int64? {
|
||||
let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
let text: String
|
||||
do {
|
||||
text = try String(contentsOf: url, encoding: .utf8)
|
||||
} catch {
|
||||
throw CLIError(message: "Failed to read events cursor file \(url.path): \(String(describing: error))")
|
||||
}
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let sequence = Int64(trimmed), sequence >= 0 else {
|
||||
throw CLIError(message: "Malformed events cursor file \(url.path): expected a non-negative sequence number")
|
||||
}
|
||||
return sequence
|
||||
}
|
||||
|
||||
private func writeEventCursor(_ seq: Int64, to path: String) throws {
|
||||
let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try "\(seq)\n".write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func int64Value(_ value: Any?) -> Int64? {
|
||||
if let number = value as? NSNumber {
|
||||
guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
|
||||
let type = String(cString: number.objCType)
|
||||
guard ["c", "C", "s", "S", "i", "I", "l", "L", "q", "Q"].contains(type) else { return nil }
|
||||
let int64 = number.int64Value
|
||||
guard number.compare(NSNumber(value: int64)) == .orderedSame else { return nil }
|
||||
return int64
|
||||
}
|
||||
if let string = value as? String { return Int64(string) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import CMUXAgentLaunch
|
||||
|
||||
extension CMUXCLI {
|
||||
func hermesAgentShellCommand(_ script: String) -> String {
|
||||
"sh -c \(shellQuote(script))"
|
||||
}
|
||||
|
||||
func hermesAgentEvents(def: AgentHookDef) -> [HermesAgentHookConfig.Event] {
|
||||
var events = def.events.map { event in
|
||||
HermesAgentHookConfig.Event(
|
||||
name: event.agentEvent,
|
||||
command: hermesAgentShellCommand(hookCommand(for: def, event: event)),
|
||||
timeout: 5
|
||||
)
|
||||
}
|
||||
events.append(contentsOf: def.feedHookEvents.map { agentEvent in
|
||||
HermesAgentHookConfig.Event(
|
||||
name: agentEvent,
|
||||
command: hermesAgentShellCommand(feedHookCommand(for: def, agentEvent: agentEvent)),
|
||||
timeout: 120
|
||||
)
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
func installHermesAgentHooks(_ def: AgentHookDef) throws {
|
||||
let fm = FileManager.default
|
||||
let configDir = def.resolvedConfigDir()
|
||||
let filePath = "\(configDir)/\(def.configFile)"
|
||||
let allowlistPath = "\(configDir)/shell-hooks-allowlist.json"
|
||||
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
|
||||
|| ProcessInfo.processInfo.arguments.contains("-y")
|
||||
|
||||
guard fm.fileExists(atPath: configDir) else {
|
||||
print("\(configDir) does not exist. Install \(def.displayName) first.")
|
||||
return
|
||||
}
|
||||
|
||||
let events = hermesAgentEvents(def: def)
|
||||
let oldString = try readAgentHookConfig(filePath: filePath, displayName: def.displayName)
|
||||
let newString = HermesAgentHookConfig.installing(events: events, in: oldString)
|
||||
|
||||
if oldString != newString {
|
||||
if !skipConfirm {
|
||||
Self.printInstallPreview(
|
||||
path: filePath,
|
||||
oldContent: oldString,
|
||||
newContent: newString,
|
||||
fallbackContent: newString
|
||||
)
|
||||
print("\nProceed? [y/N] ", terminator: "")
|
||||
guard readLine()?.lowercased().hasPrefix("y") == true else {
|
||||
print("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
try newString.write(toFile: filePath, atomically: true, encoding: .utf8)
|
||||
print("\(def.displayName) hooks installed at \(filePath)")
|
||||
} else {
|
||||
print("\(def.displayName) hooks already up to date at \(filePath)")
|
||||
}
|
||||
|
||||
let oldAllowlist = fm.contents(atPath: allowlistPath)
|
||||
let newAllowlist = try HermesAgentHookAllowlist.installing(events: events, in: oldAllowlist)
|
||||
if oldAllowlist != newAllowlist {
|
||||
try newAllowlist.write(to: URL(fileURLWithPath: allowlistPath), options: .atomic)
|
||||
print("Approved \(def.displayName) cmux shell hooks in \(allowlistPath)")
|
||||
}
|
||||
}
|
||||
|
||||
func uninstallHermesAgentHooks(_ def: AgentHookDef) throws {
|
||||
let fm = FileManager.default
|
||||
let configDir = def.resolvedConfigDir()
|
||||
let filePath = "\(configDir)/\(def.configFile)"
|
||||
let allowlistPath = "\(configDir)/shell-hooks-allowlist.json"
|
||||
let events = hermesAgentEvents(def: def)
|
||||
|
||||
if fm.fileExists(atPath: filePath) {
|
||||
let oldString = try readAgentHookConfig(filePath: filePath, displayName: def.displayName)
|
||||
let newString = HermesAgentHookConfig.uninstalling(from: oldString)
|
||||
if oldString != newString {
|
||||
try newString.write(toFile: filePath, atomically: true, encoding: .utf8)
|
||||
print("Removed Hermes Agent cmux hooks from \(filePath)")
|
||||
} else {
|
||||
print("Removed 0 cmux hook(s) from \(filePath)")
|
||||
}
|
||||
} else {
|
||||
print("No \(def.configFile) found at \(filePath)")
|
||||
}
|
||||
|
||||
guard fm.fileExists(atPath: allowlistPath) else { return }
|
||||
let oldAllowlist = fm.contents(atPath: allowlistPath)
|
||||
let newAllowlist = try HermesAgentHookAllowlist.uninstalling(events: events, from: oldAllowlist)
|
||||
if oldAllowlist != newAllowlist {
|
||||
try newAllowlist.write(to: URL(fileURLWithPath: allowlistPath), options: .atomic)
|
||||
print("Removed Hermes Agent cmux shell hook approvals from \(allowlistPath)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
extension CMUXCLI {
|
||||
private enum AnsiStyle {
|
||||
static func reset(_ tty: Bool) -> String { tty ? "\u{001B}[0m" : "" }
|
||||
static func bold(_ tty: Bool) -> String { tty ? "\u{001B}[1m" : "" }
|
||||
static func dim(_ tty: Bool) -> String { tty ? "\u{001B}[2m" : "" }
|
||||
static func red(_ tty: Bool) -> String { tty ? "\u{001B}[31m" : "" }
|
||||
static func green(_ tty: Bool) -> String { tty ? "\u{001B}[32m" : "" }
|
||||
static func yellow(_ tty: Bool) -> String { tty ? "\u{001B}[33m" : "" }
|
||||
static func magenta(_ tty: Bool) -> String { tty ? "\u{001B}[35m" : "" }
|
||||
static func cyan(_ tty: Bool) -> String { tty ? "\u{001B}[36m" : "" }
|
||||
}
|
||||
|
||||
/// Prints a colored diff preview framed with the target path both
|
||||
/// above and below the diff. Every changed line is prefixed with
|
||||
/// `+` (addition) or `-` (deletion) and colored accordingly so the
|
||||
/// user can see at a glance what's being added/removed. A summary
|
||||
/// line above the diff counts adds/deletes so users who pipe the
|
||||
/// output into tee/less still see the shape of the change.
|
||||
static func printInstallPreview(
|
||||
path: String,
|
||||
oldContent: String,
|
||||
newContent: String,
|
||||
fallbackContent: String
|
||||
) {
|
||||
let tty = isatty(fileno(stdout)) != 0
|
||||
let verb = oldContent.isEmpty ? "create" : "update"
|
||||
let header = AnsiStyle.bold(tty)
|
||||
+ "─── Will \(verb) \(path) ───" + AnsiStyle.reset(tty)
|
||||
let footer = AnsiStyle.bold(tty)
|
||||
+ "─── The above will be written to \(path) ───" + AnsiStyle.reset(tty)
|
||||
|
||||
print("")
|
||||
print(header)
|
||||
|
||||
if oldContent == newContent {
|
||||
print(AnsiStyle.dim(tty) + "(no changes — file already matches target)" + AnsiStyle.reset(tty))
|
||||
print(footer)
|
||||
return
|
||||
}
|
||||
|
||||
if let diff = unifiedDiff(old: oldContent, new: newContent), !diff.isEmpty {
|
||||
let (adds, dels) = countAddDeleteLines(diff)
|
||||
let summary = AnsiStyle.bold(tty)
|
||||
+ AnsiStyle.green(tty) + "+\(adds) additions" + AnsiStyle.reset(tty)
|
||||
+ AnsiStyle.bold(tty) + ", "
|
||||
+ AnsiStyle.red(tty) + "-\(dels) deletions" + AnsiStyle.reset(tty)
|
||||
print(summary)
|
||||
print("")
|
||||
print(colorizeDiff(diff, tty: tty))
|
||||
} else {
|
||||
// Diff unavailable (binary `/usr/bin/diff` missing, or temp-file
|
||||
// write failed). Fall back to full pretty-printed content.
|
||||
print(AnsiStyle.bold(tty) + "(diff unavailable — full content follows)" + AnsiStyle.reset(tty))
|
||||
for line in fallbackContent.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
let plus = AnsiStyle.green(tty) + AnsiStyle.bold(tty) + "+" + AnsiStyle.reset(tty)
|
||||
print("\(plus) " + jsonHighlight(String(line), tty: tty))
|
||||
}
|
||||
}
|
||||
print(footer)
|
||||
}
|
||||
|
||||
/// Counts added and deleted lines in a unified-diff body, ignoring
|
||||
/// file header lines (`+++ …`, `--- …`).
|
||||
private static func countAddDeleteLines(_ diff: String) -> (adds: Int, dels: Int) {
|
||||
var adds = 0
|
||||
var dels = 0
|
||||
for raw in diff.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
let line = String(raw)
|
||||
if line.hasPrefix("+++") || line.hasPrefix("---") { continue }
|
||||
if line.hasPrefix("+") { adds += 1 }
|
||||
if line.hasPrefix("-") { dels += 1 }
|
||||
}
|
||||
return (adds, dels)
|
||||
}
|
||||
|
||||
/// Shells out to `/usr/bin/diff -u` against two temp files. Returns
|
||||
/// nil if diff isn't available or both inputs are identical.
|
||||
private static func unifiedDiff(old: String, new: String) -> String? {
|
||||
if old == new { return "" }
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let oldURL = tempDir.appendingPathComponent("cmux-old-\(UUID().uuidString)")
|
||||
let newURL = tempDir.appendingPathComponent("cmux-new-\(UUID().uuidString)")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: oldURL)
|
||||
try? FileManager.default.removeItem(at: newURL)
|
||||
}
|
||||
do {
|
||||
try old.write(to: oldURL, atomically: true, encoding: .utf8)
|
||||
try new.write(to: newURL, atomically: true, encoding: .utf8)
|
||||
} catch { return nil }
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/diff")
|
||||
process.arguments = ["-u", oldURL.path, newURL.path]
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
var output = Data()
|
||||
let outputLock = NSLock()
|
||||
pipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
outputLock.lock()
|
||||
output.append(data)
|
||||
outputLock.unlock()
|
||||
}
|
||||
do {
|
||||
try process.run()
|
||||
} catch { return nil }
|
||||
process.waitUntilExit()
|
||||
pipe.fileHandleForReading.readabilityHandler = nil
|
||||
let remaining = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
if !remaining.isEmpty {
|
||||
outputLock.lock()
|
||||
output.append(remaining)
|
||||
outputLock.unlock()
|
||||
}
|
||||
// diff exits with 1 on differences; that's fine.
|
||||
outputLock.lock()
|
||||
let data = output
|
||||
outputLock.unlock()
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Colors a unified diff: file headers dim, hunks cyan, additions
|
||||
/// bright-green-bold, deletions bright-red-bold, context uncolored.
|
||||
/// Non-tty output still retains the leading +/- markers so the
|
||||
/// change shape is obvious even when piped into a file or pager.
|
||||
private static func colorizeDiff(_ diff: String, tty: Bool) -> String {
|
||||
var out: [String] = []
|
||||
for line in diff.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
let s = String(line)
|
||||
if s.hasPrefix("+++") || s.hasPrefix("---") {
|
||||
out.append(AnsiStyle.dim(tty) + s + AnsiStyle.reset(tty))
|
||||
} else if s.hasPrefix("@@") {
|
||||
out.append(AnsiStyle.cyan(tty) + s + AnsiStyle.reset(tty))
|
||||
} else if s.hasPrefix("+") {
|
||||
out.append(AnsiStyle.bold(tty) + AnsiStyle.green(tty) + s + AnsiStyle.reset(tty))
|
||||
} else if s.hasPrefix("-") {
|
||||
out.append(AnsiStyle.bold(tty) + AnsiStyle.red(tty) + s + AnsiStyle.reset(tty))
|
||||
} else {
|
||||
out.append(s)
|
||||
}
|
||||
}
|
||||
return out.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Tiny JSON syntax highlighter. Strings cyan; numbers yellow;
|
||||
/// booleans/null magenta. Keys stay uncolored because we'd need a
|
||||
/// lookahead parser to reliably distinguish them from string values.
|
||||
private static func jsonHighlight(_ json: String, tty: Bool) -> String {
|
||||
guard tty else { return json }
|
||||
var out = ""
|
||||
out.reserveCapacity(json.count + 32)
|
||||
var i = json.startIndex
|
||||
while i < json.endIndex {
|
||||
let c = json[i]
|
||||
if c == "\"" {
|
||||
// Scan to closing quote, honoring escapes.
|
||||
let start = i
|
||||
i = json.index(after: i)
|
||||
while i < json.endIndex {
|
||||
let ch = json[i]
|
||||
if ch == "\\", json.index(after: i) < json.endIndex {
|
||||
i = json.index(i, offsetBy: 2)
|
||||
continue
|
||||
}
|
||||
if ch == "\"" {
|
||||
i = json.index(after: i)
|
||||
break
|
||||
}
|
||||
i = json.index(after: i)
|
||||
}
|
||||
let lit = String(json[start..<i])
|
||||
out += AnsiStyle.cyan(tty) + lit + AnsiStyle.reset(tty)
|
||||
continue
|
||||
}
|
||||
if c.isNumber || (c == "-" && json.index(after: i) < json.endIndex && json[json.index(after: i)].isNumber) {
|
||||
let start = i
|
||||
while i < json.endIndex, let ch = Optional(json[i]),
|
||||
ch.isNumber || ch == "." || ch == "-" || ch == "+" || ch == "e" || ch == "E" {
|
||||
i = json.index(after: i)
|
||||
}
|
||||
out += AnsiStyle.yellow(tty) + String(json[start..<i]) + AnsiStyle.reset(tty)
|
||||
continue
|
||||
}
|
||||
if json[i...].hasPrefix("true") || json[i...].hasPrefix("false") || json[i...].hasPrefix("null") {
|
||||
let token = json[i...].hasPrefix("false") ? "false"
|
||||
: (json[i...].hasPrefix("true") ? "true" : "null")
|
||||
out += AnsiStyle.magenta(tty) + token + AnsiStyle.reset(tty)
|
||||
i = json.index(i, offsetBy: token.count)
|
||||
continue
|
||||
}
|
||||
out.append(c)
|
||||
i = json.index(after: i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
struct CLIProcessResult {
|
||||
let status: Int32
|
||||
let stdout: String
|
||||
let stderr: String
|
||||
let timedOut: Bool
|
||||
}
|
||||
|
||||
enum CLIProcessRunner {
|
||||
static func runProcess(
|
||||
executablePath: String,
|
||||
arguments: [String],
|
||||
stdinText: String? = nil,
|
||||
timeout: TimeInterval? = nil
|
||||
) -> CLIProcessResult {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executablePath)
|
||||
process.arguments = arguments
|
||||
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
let stdinPipe: Pipe?
|
||||
if stdinText != nil {
|
||||
let pipe = Pipe()
|
||||
process.standardInput = pipe
|
||||
stdinPipe = pipe
|
||||
} else {
|
||||
stdinPipe = nil
|
||||
}
|
||||
|
||||
let finished = DispatchSemaphore(value: 0)
|
||||
process.terminationHandler = { _ in
|
||||
finished.signal()
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return CLIProcessResult(status: 1, stdout: "", stderr: String(describing: error), timedOut: false)
|
||||
}
|
||||
|
||||
if let stdinText, let stdinPipe {
|
||||
if let data = stdinText.data(using: .utf8) {
|
||||
stdinPipe.fileHandleForWriting.write(data)
|
||||
}
|
||||
stdinPipe.fileHandleForWriting.closeFile()
|
||||
}
|
||||
|
||||
let timedOut: Bool
|
||||
if let timeout {
|
||||
switch finished.wait(timeout: .now() + timeout) {
|
||||
case .success:
|
||||
timedOut = false
|
||||
case .timedOut:
|
||||
timedOut = true
|
||||
terminate(process: process, finished: finished)
|
||||
}
|
||||
} else {
|
||||
finished.wait()
|
||||
timedOut = false
|
||||
}
|
||||
|
||||
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
var stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
if timedOut {
|
||||
let timeoutMessage = "process timed out"
|
||||
if stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
stderr = timeoutMessage
|
||||
} else if !stderr.contains(timeoutMessage) {
|
||||
stderr += "\n\(timeoutMessage)"
|
||||
}
|
||||
}
|
||||
|
||||
return CLIProcessResult(
|
||||
status: timedOut ? 124 : process.terminationStatus,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
timedOut: timedOut
|
||||
)
|
||||
}
|
||||
|
||||
private static func terminate(process: Process, finished: DispatchSemaphore) {
|
||||
guard process.isRunning else { return }
|
||||
process.terminate()
|
||||
if finished.wait(timeout: .now() + 0.5) == .success {
|
||||
return
|
||||
}
|
||||
if process.isRunning {
|
||||
kill(process.processIdentifier, SIGKILL)
|
||||
}
|
||||
_ = finished.wait(timeout: .now() + 0.5)
|
||||
}
|
||||
}
|
||||
+1862
-673
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-argument-parser",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
|
||||
"version" : "1.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftterm",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/migueldeicaza/SwiftTerm.git",
|
||||
"state" : {
|
||||
"revision" : "0b8d99bd19b694df44e1ccaa3891309719d34330",
|
||||
"version" : "1.5.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "cmux",
|
||||
platforms: [
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "cmux", targets: ["cmux"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "Packages/CMUXRovoDevIndex"),
|
||||
.package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "cmux",
|
||||
dependencies: ["CMUXRovoDevIndex", "SwiftTerm"],
|
||||
path: "Sources"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -12,9 +12,13 @@ let package = Package(
|
||||
targets: ["CMUXAgentLaunch"]
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../CMUXAgentVault"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CMUXAgentLaunch"
|
||||
name: "CMUXAgentLaunch",
|
||||
dependencies: ["CMUXAgentVault"]
|
||||
),
|
||||
.testTarget(
|
||||
name: "CMUXAgentLaunchTests",
|
||||
|
||||
@@ -25,6 +25,12 @@ public enum ClaudeConfigDirectoryPath {
|
||||
|
||||
public enum AgentLaunchEnvironmentPolicy {
|
||||
private static let safeEnvironmentKeys: Set<String> = [
|
||||
// AMP_API_KEY is intentionally NOT allowlisted: it's a secret.
|
||||
// Amp resolves auth from ~/.config/amp/settings.json on resume.
|
||||
"AMP_LOG_FILE",
|
||||
"AMP_LOG_LEVEL",
|
||||
"AMP_SETTINGS_FILE",
|
||||
"AMP_URL",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_MODEL",
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
@@ -50,8 +56,15 @@ public enum AgentLaunchEnvironmentPolicy {
|
||||
"COPILOT_PROVIDER_WIRE_MODEL",
|
||||
"GEMINI_CLI_HOME",
|
||||
"GH_HOST",
|
||||
"HERMES_HOME",
|
||||
"NODE_OPTIONS",
|
||||
"OPENCODE_CONFIG_DIR",
|
||||
"PI_CACHE_RETENTION",
|
||||
"PI_CODING_AGENT_DIR",
|
||||
"PI_CODING_AGENT_SESSION_DIR",
|
||||
"PI_OFFLINE",
|
||||
"PI_PACKAGE_DIR",
|
||||
"PI_SKIP_VERSION_CHECK",
|
||||
"QODER_CONFIG_DIR",
|
||||
"USE_BUILTIN_RIPGREP"
|
||||
]
|
||||
|
||||
@@ -57,6 +57,26 @@ public enum AgentLaunchSanitizer {
|
||||
return preserveOptions(args, policy: claudePolicy)
|
||||
case "codex":
|
||||
return preserveOptions(args, policy: codexPolicy)
|
||||
case "pi":
|
||||
return preserveOptions(args, policy: piPolicy)
|
||||
case "amp":
|
||||
// Strip the `threads continue <id>` resume sub-subcommand if the
|
||||
// captured launch already started by resuming a thread, so we
|
||||
// don't double-add it. Supports the documented short aliases:
|
||||
// `t`/`thread` for `threads`, and `c` for `continue`.
|
||||
var tail = args
|
||||
let threadsAliases: Set<String> = ["threads", "thread", "t"]
|
||||
let continueAliases: Set<String> = ["continue", "c"]
|
||||
if let first = tail.first, threadsAliases.contains(first) {
|
||||
tail.removeFirst()
|
||||
if let next = tail.first, continueAliases.contains(next) {
|
||||
tail.removeFirst()
|
||||
if let candidate = tail.first, !candidate.hasPrefix("-") {
|
||||
tail.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
return preserveOptions(tail, policy: ampPolicy)
|
||||
case "cursor":
|
||||
var tail = args
|
||||
if tail.first == "agent" {
|
||||
@@ -81,6 +101,15 @@ public enum AgentLaunchSanitizer {
|
||||
return nil
|
||||
}
|
||||
return preserveOptions(tail, policy: rovoDevPolicy)
|
||||
case "hermes-agent":
|
||||
var tail = args
|
||||
if tail.first == "chat" {
|
||||
tail.removeFirst()
|
||||
} else if let command = tail.first,
|
||||
!command.hasPrefix("-") {
|
||||
return nil
|
||||
}
|
||||
return preserveOptions(tail, policy: hermesAgentPolicy)
|
||||
case "copilot":
|
||||
return preserveOptions(args, policy: copilotPolicy)
|
||||
case "codebuddy":
|
||||
|
||||
+64
@@ -385,4 +385,68 @@ extension AgentLaunchSanitizer {
|
||||
"-o"
|
||||
]
|
||||
)
|
||||
|
||||
static let hermesAgentPolicy = Policy(
|
||||
// Boolean flags such as --tui pass through by default unless they are
|
||||
// explicitly rejected or dropped below.
|
||||
valueOptions: [
|
||||
"--api-key",
|
||||
"--base-url",
|
||||
"--image",
|
||||
"--max-turns",
|
||||
"--model",
|
||||
"-m",
|
||||
"--profile",
|
||||
"-p",
|
||||
"--provider",
|
||||
"--resume",
|
||||
"-r",
|
||||
"--skills",
|
||||
"-s",
|
||||
"--source",
|
||||
"--toolsets",
|
||||
"-t",
|
||||
"--worktree",
|
||||
"-w"
|
||||
],
|
||||
optionalValueOptions: [
|
||||
"--continue",
|
||||
"-c"
|
||||
],
|
||||
nonRestorableCommands: [],
|
||||
droppedOptions: [
|
||||
"--api-key",
|
||||
"--continue",
|
||||
"-c",
|
||||
"--image",
|
||||
"--resume",
|
||||
"-r",
|
||||
"--source",
|
||||
"--verbose",
|
||||
"-v",
|
||||
"--worktree",
|
||||
"-w"
|
||||
],
|
||||
droppedOptionPrefixes: [
|
||||
"--api-key=",
|
||||
"--continue=",
|
||||
"-c=",
|
||||
"--image=",
|
||||
"--resume=",
|
||||
"-r=",
|
||||
"--source=",
|
||||
"--worktree=",
|
||||
"-w="
|
||||
],
|
||||
rejectOptions: [
|
||||
"--oneshot",
|
||||
"-z",
|
||||
"--query",
|
||||
"-q",
|
||||
"--quiet",
|
||||
"-Q",
|
||||
"--list-tools",
|
||||
"--list-toolsets"
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
+110
@@ -10,6 +10,7 @@ extension AgentLaunchSanitizer {
|
||||
"--allowed-tools",
|
||||
"--append-system-prompt",
|
||||
"--betas",
|
||||
"--dangerously-load-development-channels",
|
||||
"--debug-file",
|
||||
"--disallowedTools",
|
||||
"--disallowed-tools",
|
||||
@@ -156,6 +157,115 @@ extension AgentLaunchSanitizer {
|
||||
resumeSubcommand: "resume"
|
||||
)
|
||||
|
||||
static let piPolicy = Policy(
|
||||
valueOptions: [
|
||||
"--append-system-prompt",
|
||||
"--api-key",
|
||||
"--extension",
|
||||
"--fork",
|
||||
"--model",
|
||||
"--models",
|
||||
"--prompt-template",
|
||||
"--provider",
|
||||
"--resume",
|
||||
"--session",
|
||||
"--session-dir",
|
||||
"--skill",
|
||||
"--system-prompt",
|
||||
"--theme",
|
||||
"--thinking",
|
||||
"--tools",
|
||||
"-e",
|
||||
"-t"
|
||||
],
|
||||
nonRestorableCommands: [
|
||||
"config",
|
||||
"help",
|
||||
"install",
|
||||
"list",
|
||||
"login",
|
||||
"logout",
|
||||
"remove",
|
||||
"uninstall",
|
||||
"update"
|
||||
],
|
||||
droppedOptions: [
|
||||
"--api-key",
|
||||
"--continue",
|
||||
"--fork",
|
||||
"--resume",
|
||||
"--session",
|
||||
"-c",
|
||||
"-r"
|
||||
],
|
||||
droppedOptionPrefixes: [
|
||||
"--api-key=",
|
||||
"--fork=",
|
||||
"--resume=",
|
||||
"--session="
|
||||
],
|
||||
rejectOptions: [
|
||||
"--export",
|
||||
"--list-models",
|
||||
"--mode",
|
||||
"--no-session",
|
||||
"--print",
|
||||
"--prompt",
|
||||
"--version",
|
||||
"-h",
|
||||
"-p",
|
||||
"-v"
|
||||
]
|
||||
)
|
||||
|
||||
static let ampPolicy = Policy(
|
||||
valueOptions: [
|
||||
"--effort",
|
||||
// --label takes a value; listed here AND in droppedOptions so the
|
||||
// sanitizer consumes the value too (otherwise it slips through as
|
||||
// a positional).
|
||||
"--label",
|
||||
"--log-file",
|
||||
"--log-level",
|
||||
"--mcp-config",
|
||||
"--mode",
|
||||
"--settings-file",
|
||||
"--visibility",
|
||||
"-l",
|
||||
"-m"
|
||||
],
|
||||
nonRestorableCommands: [
|
||||
"login",
|
||||
"logout",
|
||||
"mcp",
|
||||
"permissions",
|
||||
"permission",
|
||||
"review",
|
||||
"skill",
|
||||
"skills",
|
||||
"tool",
|
||||
"tools",
|
||||
"update",
|
||||
"up",
|
||||
"usage",
|
||||
"version"
|
||||
],
|
||||
droppedOptions: [
|
||||
"--archive",
|
||||
"--label",
|
||||
"-l",
|
||||
"--stream-json",
|
||||
"--stream-json-input",
|
||||
"--stream-json-thinking"
|
||||
],
|
||||
rejectOptions: [
|
||||
"--execute",
|
||||
"--print",
|
||||
"-V",
|
||||
"-x"
|
||||
]
|
||||
)
|
||||
|
||||
static let geminiPolicy = Policy(
|
||||
valueOptions: [
|
||||
"--model",
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import Foundation
|
||||
|
||||
public enum HermesAgentHookConfig {
|
||||
public struct Event: Equatable, Sendable {
|
||||
public var name: String
|
||||
public var command: String
|
||||
public var timeout: Int
|
||||
public var matcher: String?
|
||||
|
||||
public init(name: String, command: String, timeout: Int = 5, matcher: String? = nil) {
|
||||
self.name = name
|
||||
self.command = command
|
||||
self.timeout = timeout
|
||||
self.matcher = matcher
|
||||
}
|
||||
}
|
||||
|
||||
private static let beginMarker = "# cmux hooks hermes-agent begin"
|
||||
private static let endMarker = "# cmux hooks hermes-agent end"
|
||||
private static let restoreLineMarkerPrefix = "\(beginMarker) restore-line-base64:"
|
||||
|
||||
public static func installing(events: [Event], in existing: String) -> String {
|
||||
guard !events.isEmpty else {
|
||||
return uninstalling(from: existing)
|
||||
}
|
||||
|
||||
var lines = normalizedLines(existing)
|
||||
lines = removingMarkedBlocks(lines)
|
||||
|
||||
if let hooksIndex = hooksLineIndex(in: lines) {
|
||||
let hooksRestoreLine: String?
|
||||
if inlineEmptyHooksLine(lines[hooksIndex]) {
|
||||
hooksRestoreLine = lines[hooksIndex]
|
||||
lines[hooksIndex] = "\(leadingWhitespace(lines[hooksIndex]))hooks:"
|
||||
} else {
|
||||
hooksRestoreLine = nil
|
||||
}
|
||||
let childIndent = leadingWhitespace(lines[hooksIndex]) + " "
|
||||
let existingEvents = directEventLineIndexes(in: lines, hooksIndex: hooksIndex)
|
||||
var missingEvents: [Event] = []
|
||||
var matchedEvents: [(event: Event, eventIndex: Int)] = []
|
||||
|
||||
for event in events {
|
||||
guard let eventIndex = existingEvents[event.name] else {
|
||||
missingEvents.append(event)
|
||||
continue
|
||||
}
|
||||
matchedEvents.append((event, eventIndex))
|
||||
}
|
||||
|
||||
for (event, eventIndex) in matchedEvents.sorted(by: { $0.eventIndex > $1.eventIndex }) {
|
||||
let eventRestoreLine: String?
|
||||
if inlineEmptyEventLine(lines[eventIndex]) {
|
||||
let originalLine = lines[eventIndex]
|
||||
let headerLine = emptyEventHeaderLine(originalLine)
|
||||
eventRestoreLine = originalLine == headerLine ? nil : originalLine
|
||||
lines[eventIndex] = headerLine
|
||||
} else {
|
||||
eventRestoreLine = nil
|
||||
}
|
||||
let entryIndent = leadingWhitespace(lines[eventIndex]) + " "
|
||||
let block = hookListBlock(events: [event], itemIndent: entryIndent, restoreLine: eventRestoreLine)
|
||||
lines.insert(contentsOf: block, at: eventIndex + 1)
|
||||
}
|
||||
|
||||
if !missingEvents.isEmpty {
|
||||
let block = eventSectionsBlock(
|
||||
events: missingEvents,
|
||||
childIndent: childIndent,
|
||||
restoreLine: hooksRestoreLine
|
||||
)
|
||||
lines.insert(contentsOf: block, at: hooksIndex + 1)
|
||||
}
|
||||
} else {
|
||||
if !lines.isEmpty, lines.last?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
lines.append("")
|
||||
}
|
||||
lines.append(beginMarker)
|
||||
lines.append("hooks:")
|
||||
lines.append(contentsOf: eventSectionsBlock(events: events, childIndent: " ", includeMarkers: false))
|
||||
lines.append(endMarker)
|
||||
}
|
||||
|
||||
return serialized(lines)
|
||||
}
|
||||
|
||||
public static func uninstalling(from existing: String) -> String {
|
||||
serialized(removingMarkedBlocks(normalizedLines(existing)))
|
||||
}
|
||||
|
||||
private static func normalizedLines(_ content: String) -> [String] {
|
||||
var lines = content
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n")
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.map(String.init)
|
||||
if lines.last == "" {
|
||||
lines.removeLast()
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func serialized(_ lines: [String]) -> String {
|
||||
lines.isEmpty ? "" : lines.joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
private static func eventSectionsBlock(
|
||||
events: [Event],
|
||||
childIndent: String,
|
||||
includeMarkers: Bool = true,
|
||||
restoreLine: String? = nil
|
||||
) -> [String] {
|
||||
var lines: [String] = []
|
||||
if includeMarkers {
|
||||
lines.append("\(childIndent)\(beginMarkerLine(restoreLine: restoreLine))")
|
||||
}
|
||||
for event in events {
|
||||
lines.append("\(childIndent)\(event.name):")
|
||||
lines.append(contentsOf: hookEntries(events: [event], itemIndent: childIndent + " "))
|
||||
}
|
||||
if includeMarkers {
|
||||
lines.append("\(childIndent)\(endMarker)")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func hookListBlock(events: [Event], itemIndent: String, restoreLine: String? = nil) -> [String] {
|
||||
var lines = ["\(itemIndent)\(beginMarkerLine(restoreLine: restoreLine))"]
|
||||
lines.append(contentsOf: hookEntries(events: events, itemIndent: itemIndent))
|
||||
lines.append("\(itemIndent)\(endMarker)")
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func hookEntries(events: [Event], itemIndent: String) -> [String] {
|
||||
var lines: [String] = []
|
||||
for event in events {
|
||||
lines.append("\(itemIndent)- command: \(yamlDoubleQuoted(event.command))")
|
||||
if let matcher = event.matcher?.trimmingCharacters(in: .whitespacesAndNewlines), !matcher.isEmpty {
|
||||
lines.append("\(itemIndent) matcher: \(yamlDoubleQuoted(matcher))")
|
||||
}
|
||||
lines.append("\(itemIndent) timeout: \(event.timeout)")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func removingMarkedBlocks(_ lines: [String]) -> [String] {
|
||||
var result = lines
|
||||
var index = 0
|
||||
while index < result.count {
|
||||
guard isBeginMarkerLine(result[index]) else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
guard let endIndex = result[(index + 1)...].firstIndex(where: {
|
||||
$0.trimmingCharacters(in: .whitespaces) == endMarker
|
||||
}) else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if let restoreLine = restoreLine(fromBeginMarkerLine: result[index]),
|
||||
result.indices.contains(index - 1) {
|
||||
result[index - 1] = restoreLine
|
||||
result.removeSubrange(index...endIndex)
|
||||
continue
|
||||
}
|
||||
let removalStart = result.indices.contains(index - 1)
|
||||
&& result[index - 1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? index - 1
|
||||
: index
|
||||
result.removeSubrange(removalStart...endIndex)
|
||||
index = removalStart
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func beginMarkerLine(restoreLine: String?) -> String {
|
||||
guard let restoreLine else { return beginMarker }
|
||||
let encoded = Data(restoreLine.utf8).base64EncodedString()
|
||||
return "\(restoreLineMarkerPrefix) \(encoded)"
|
||||
}
|
||||
|
||||
private static func isBeginMarkerLine(_ line: String) -> Bool {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
return trimmed == beginMarker || trimmed.hasPrefix("\(restoreLineMarkerPrefix) ")
|
||||
}
|
||||
|
||||
private static func restoreLine(fromBeginMarkerLine line: String) -> String? {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed.hasPrefix("\(restoreLineMarkerPrefix) ") else { return nil }
|
||||
let encoded = trimmed.dropFirst(restoreLineMarkerPrefix.count)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard let data = Data(base64Encoded: encoded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func hooksLineIndex(in lines: [String]) -> Int? {
|
||||
lines.firstIndex { line in
|
||||
leadingWhitespace(line).isEmpty
|
||||
&& line.range(of: #"^hooks:\s*((\{\}|\[\])\s*)?(#.*)?$"#, options: .regularExpression) != nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func inlineEmptyHooksLine(_ line: String) -> Bool {
|
||||
line.range(of: #"^hooks:\s*(\{\}|\[\])\s*(#.*)?$"#, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private static func inlineEmptyEventLine(_ line: String) -> Bool {
|
||||
guard let colon = line.firstIndex(of: ":") else { return false }
|
||||
let suffix = line[line.index(after: colon)...]
|
||||
return suffixIsInlineEmptyMapOrList(suffix)
|
||||
}
|
||||
|
||||
private static func emptyEventHeaderLine(_ line: String) -> String {
|
||||
guard let colon = line.firstIndex(of: ":") else { return line }
|
||||
return String(line[...colon])
|
||||
}
|
||||
|
||||
private static func suffixIsInlineEmptyMapOrList(_ suffix: Substring) -> Bool {
|
||||
let uncommented = suffix.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first ?? ""
|
||||
let trimmed = uncommented.trimmingCharacters(in: .whitespaces)
|
||||
return trimmed.isEmpty || trimmed == "{}" || trimmed == "[]"
|
||||
}
|
||||
|
||||
private static func directEventLineIndexes(in lines: [String], hooksIndex: Int) -> [String: Int] {
|
||||
let hooksIndent = leadingWhitespace(lines[hooksIndex])
|
||||
let childIndent = hooksIndent + " "
|
||||
var indexes: [String: Int] = [:]
|
||||
|
||||
var index = hooksIndex + 1
|
||||
while index < lines.count {
|
||||
let line = lines[index]
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
guard line.hasPrefix(childIndent) else {
|
||||
break
|
||||
}
|
||||
guard leadingWhitespace(line) == childIndent,
|
||||
let colon = trimmed.firstIndex(of: ":") else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let name = String(trimmed[..<colon])
|
||||
let suffix = trimmed[trimmed.index(after: colon)...]
|
||||
if suffixIsInlineEmptyMapOrList(suffix) {
|
||||
indexes[name] = index
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
private static func leadingWhitespace(_ line: String) -> String {
|
||||
String(line.prefix { $0 == " " || $0 == "\t" })
|
||||
}
|
||||
|
||||
private static func yamlDoubleQuoted(_ value: String) -> String {
|
||||
var escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
escaped = escaped.replacingOccurrences(of: "\n", with: "\\n")
|
||||
return "\"\(escaped)\""
|
||||
}
|
||||
}
|
||||
|
||||
public enum HermesAgentHookAllowlist {
|
||||
enum Error: Swift.Error, Equatable {
|
||||
case invalidRoot
|
||||
}
|
||||
|
||||
public static func installing(events: [HermesAgentHookConfig.Event], in existing: Data?, approvedAt: Date = Date()) throws -> Data {
|
||||
var object = try decode(existing)
|
||||
let approvals = object["approvals"] as? [[String: Any]] ?? []
|
||||
var keyed: [String: [String: Any]] = [:]
|
||||
var passthrough: [[String: Any]] = []
|
||||
for approval in approvals {
|
||||
guard let event = approval["event"] as? String,
|
||||
let command = approval["command"] as? String else {
|
||||
passthrough.append(approval)
|
||||
continue
|
||||
}
|
||||
keyed[key(event: event, command: command)] = approval
|
||||
}
|
||||
|
||||
let iso = ISO8601DateFormatter().string(from: approvedAt)
|
||||
for event in events {
|
||||
keyed[key(event: event.name, command: event.command)] = [
|
||||
"event": event.name,
|
||||
"command": event.command,
|
||||
"approved_at": iso,
|
||||
]
|
||||
}
|
||||
let ownedApprovals = keyed.values.sorted {
|
||||
(($0["event"] as? String) ?? "", ($0["command"] as? String) ?? "")
|
||||
< ((($1["event"] as? String) ?? ""), (($1["command"] as? String) ?? ""))
|
||||
}
|
||||
object["approvals"] = passthrough + ownedApprovals
|
||||
return try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||
}
|
||||
|
||||
public static func uninstalling(events: [HermesAgentHookConfig.Event], from existing: Data?) throws -> Data {
|
||||
var object = try decode(existing)
|
||||
let ownedKeys = Set(events.map { key(event: $0.name, command: $0.command) })
|
||||
let approvals = object["approvals"] as? [[String: Any]] ?? []
|
||||
object["approvals"] = approvals.filter { approval in
|
||||
guard let event = approval["event"] as? String,
|
||||
let command = approval["command"] as? String else {
|
||||
return true
|
||||
}
|
||||
return !ownedKeys.contains(key(event: event, command: command))
|
||||
}
|
||||
return try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||
}
|
||||
|
||||
private static func decode(_ existing: Data?) throws -> [String: Any] {
|
||||
guard let existing, !existing.isEmpty else {
|
||||
return ["approvals": []]
|
||||
}
|
||||
guard let object = try JSONSerialization.jsonObject(with: existing) as? [String: Any] else {
|
||||
throw Error.invalidRoot
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
private static func key(event: String, command: String) -> String {
|
||||
"\(event)\u{0}\(command)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
public enum HermesAgentSessionResolver {
|
||||
public static func hermesHome(env: [String: String]) -> String {
|
||||
if let home = normalized(env["HERMES_HOME"]) {
|
||||
return expandedPath(home, env: env)
|
||||
}
|
||||
let baseHome = normalized(env["HOME"]) ?? NSHomeDirectory()
|
||||
return (baseHome as NSString).appendingPathComponent(".hermes")
|
||||
}
|
||||
|
||||
public static func configPath(env: [String: String]) -> String {
|
||||
(hermesHome(env: env) as NSString).appendingPathComponent("config.yaml")
|
||||
}
|
||||
|
||||
public static func stateDBPath(env: [String: String]) -> String {
|
||||
(hermesHome(env: env) as NSString).appendingPathComponent("state.db")
|
||||
}
|
||||
|
||||
public static func allowlistPath(env: [String: String]) -> String {
|
||||
(hermesHome(env: env) as NSString).appendingPathComponent("shell-hooks-allowlist.json")
|
||||
}
|
||||
|
||||
public static func expandedPath(_ path: String, env: [String: String]) -> String {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed == "~" || trimmed.hasPrefix("~/") else {
|
||||
return NSString(string: trimmed).expandingTildeInPath
|
||||
}
|
||||
let home = normalized(env["HOME"]) ?? NSHomeDirectory()
|
||||
guard trimmed != "~" else { return home }
|
||||
return (home as NSString).appendingPathComponent(String(trimmed.dropFirst(2)))
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import CMUXAgentVault
|
||||
import Foundation
|
||||
|
||||
public enum RovoDevSessionResolver {
|
||||
private struct RovoDevSessionCandidate {
|
||||
let sessionId: String
|
||||
let modified: Date
|
||||
}
|
||||
|
||||
public static func inferredRovoDevSessionId(cwd: String?, env: [String: String]) -> String? {
|
||||
let sessionsRoot = rovoDevSessionsRoot(env: env)
|
||||
let rootURL = URL(fileURLWithPath: sessionsRoot, isDirectory: true)
|
||||
@@ -13,10 +19,8 @@ public enum RovoDevSessionResolver {
|
||||
return nil
|
||||
}
|
||||
|
||||
let normalizedCwd = cwd.map {
|
||||
URL(fileURLWithPath: NSString(string: $0).expandingTildeInPath).standardizedFileURL.path
|
||||
}
|
||||
var candidates: [String] = []
|
||||
let normalizedCwd = RovoDevIndex.normalizedPath(cwd)
|
||||
var candidates: [RovoDevSessionCandidate] = []
|
||||
candidates.reserveCapacity(sessionURLs.count)
|
||||
for sessionURL in sessionURLs {
|
||||
guard (try? sessionURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else {
|
||||
@@ -27,18 +31,30 @@ public enum RovoDevSessionResolver {
|
||||
let metadata = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
continue
|
||||
}
|
||||
let workspace = (metadata["workspace_path"] as? String)
|
||||
?? (metadata["workspacePath"] as? String)
|
||||
let normalizedWorkspace = workspace.map {
|
||||
URL(fileURLWithPath: NSString(string: $0).expandingTildeInPath).standardizedFileURL.path
|
||||
}
|
||||
let workspace = RovoDevMetadataFields.workspacePath(from: metadata)
|
||||
let normalizedWorkspace = RovoDevIndex.normalizedPath(workspace)
|
||||
guard rovoDevWorkspace(normalizedWorkspace, matches: normalizedCwd) else {
|
||||
continue
|
||||
}
|
||||
candidates.append(sessionURL.lastPathComponent)
|
||||
let sessionContextURL = sessionURL.appendingPathComponent("session_context.json", isDirectory: false)
|
||||
let modified = max(
|
||||
RovoDevIndex.contentModificationDate(ofRegularFile: metadataURL) ?? Date.distantPast,
|
||||
RovoDevIndex.contentModificationDate(ofRegularFile: sessionContextURL) ?? Date.distantPast
|
||||
)
|
||||
candidates.append(RovoDevSessionCandidate(
|
||||
sessionId: sessionURL.lastPathComponent,
|
||||
modified: modified
|
||||
))
|
||||
}
|
||||
guard candidates.count == 1 else { return nil }
|
||||
return candidates.first
|
||||
candidates.sort {
|
||||
if $0.modified == $1.modified {
|
||||
// Rovo session IDs are time-ordered, so descending sessionId
|
||||
// keeps equal modified times stable while preferring newer sessions.
|
||||
return $0.sessionId > $1.sessionId
|
||||
}
|
||||
return $0.modified > $1.modified
|
||||
}
|
||||
return candidates.first?.sessionId
|
||||
}
|
||||
|
||||
public static func rovoDevSessionsRoot(env: [String: String]) -> String {
|
||||
@@ -56,7 +72,7 @@ public enum RovoDevSessionResolver {
|
||||
return rovoDevExpandedPath(persistenceDir, env: env)
|
||||
}
|
||||
|
||||
public static func rovoDevWorkspace(_ workspace: String?, matches cwd: String?) -> Bool {
|
||||
private static func rovoDevWorkspace(_ workspace: String?, matches cwd: String?) -> Bool {
|
||||
guard let cwd, !cwd.isEmpty else { return false }
|
||||
guard let workspace, !workspace.isEmpty else { return false }
|
||||
return cwd == workspace
|
||||
|
||||
@@ -35,4 +35,181 @@ struct AgentLaunchSanitizerTests {
|
||||
) == ["cursor-agent", "--model", "gpt-5.4", "--sandbox", "enabled"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Drops Pi session selectors and prompt while preserving configuration")
|
||||
func dropsPiSessionSelectorsAndPrompt() {
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
[
|
||||
"pi", "--session", "old-session", "--model", "anthropic/claude-sonnet-4-5",
|
||||
"--thinking", "high", "--api-key", "secret", "implement this",
|
||||
],
|
||||
launcher: "pi",
|
||||
fallbackKind: "pi"
|
||||
) == ["pi", "--model", "anthropic/claude-sonnet-4-5", "--thinking", "high"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Preserves repeated Pi extension and skill flags without replaying prompt")
|
||||
func preservesRepeatedPiExtensionAndSkillFlags() {
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
[
|
||||
"pi", "--extension", "a.ts", "--extension", "b.ts",
|
||||
"--skill", "review", "--skill", "swift", "initial prompt",
|
||||
],
|
||||
launcher: "pi",
|
||||
fallbackKind: "pi"
|
||||
) == [
|
||||
"pi", "--extension", "a.ts", "--extension", "b.ts",
|
||||
"--skill", "review", "--skill", "swift",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Rejects noninteractive Pi launches")
|
||||
func rejectsNoninteractivePiLaunches() {
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["pi", "--print", "summarize"],
|
||||
launcher: "pi",
|
||||
fallbackKind: "pi"
|
||||
) == nil
|
||||
)
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["pi", "--prompt", "summarize"],
|
||||
launcher: "pi",
|
||||
fallbackKind: "pi"
|
||||
) == nil
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Preserves Hermes inherited flags without replaying startup-only input")
|
||||
func preservesHermesInheritedFlagsWithoutReplayingStartupOnlyInput() {
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
[
|
||||
"hermes",
|
||||
"--profile",
|
||||
"work",
|
||||
"--tui",
|
||||
"--skills",
|
||||
"github-auth",
|
||||
"-s",
|
||||
"hermes-agent-dev",
|
||||
"--api-key",
|
||||
"secret",
|
||||
"--image",
|
||||
"/tmp/cat.png",
|
||||
"--worktree",
|
||||
"--resume",
|
||||
"old-session",
|
||||
"--source",
|
||||
"cli",
|
||||
"initial prompt should not replay",
|
||||
],
|
||||
launcher: "hermes-agent",
|
||||
fallbackKind: "hermes-agent"
|
||||
) == [
|
||||
"hermes",
|
||||
"--profile",
|
||||
"work",
|
||||
"--tui",
|
||||
"--skills",
|
||||
"github-auth",
|
||||
"-s",
|
||||
"hermes-agent-dev",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Drops Hermes worktree value before preserving later options")
|
||||
func dropsHermesWorktreeValueBeforePreservingLaterOptions() {
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["hermes", "--worktree", "/tmp/repo", "--model", "anthropic/claude-sonnet-4.6"],
|
||||
launcher: "hermes-agent",
|
||||
fallbackKind: "hermes-agent"
|
||||
) == ["hermes", "--model", "anthropic/claude-sonnet-4.6"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Allows only Hermes chat or default session launch")
|
||||
func allowsOnlyHermesChatOrDefaultSessionLaunch() {
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["hermes", "chat", "--tui", "--model", "anthropic/claude-sonnet-4.6", "initial prompt"],
|
||||
launcher: "hermes-agent",
|
||||
fallbackKind: "hermes-agent"
|
||||
) == ["hermes", "--tui", "--model", "anthropic/claude-sonnet-4.6"]
|
||||
)
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["hermes", "fallback", "list"],
|
||||
launcher: "hermes-agent",
|
||||
fallbackKind: "hermes-agent"
|
||||
) == nil
|
||||
)
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["hermes", "slack", "send"],
|
||||
launcher: "hermes-agent",
|
||||
fallbackKind: "hermes-agent"
|
||||
) == nil
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Treats Hermes skills as single value options")
|
||||
func treatsHermesSkillsAsSingleValueOptions() {
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["hermes", "--skills", "skill1", "skill2", "--model", "anthropic/claude-sonnet-4.6"],
|
||||
launcher: "hermes-agent",
|
||||
fallbackKind: "hermes-agent"
|
||||
) == ["hermes", "--skills", "skill1"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Drops Amp --label and its value while preserving later options")
|
||||
func dropsAmpLabelValueAndPreservesLaterOptions() {
|
||||
// --label takes a value. If --label isn't in valueOptions, the
|
||||
// sanitizer drops only `--label` and `foo` slips through as a
|
||||
// positional, breaking the resumed launch.
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["amp", "--label", "foo", "--mode", "geppetto"],
|
||||
launcher: "amp",
|
||||
fallbackKind: "amp"
|
||||
) == ["amp", "--mode", "geppetto"]
|
||||
)
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["amp", "-l", "bar", "--effort", "high"],
|
||||
launcher: "amp",
|
||||
fallbackKind: "amp"
|
||||
) == ["amp", "--effort", "high"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Rejects non-restorable Amp launches and strips resume preamble")
|
||||
func rejectsNonRestorableAmpLaunchesAndStripsResumePreamble() {
|
||||
// --execute / --print / -x are non-interactive runs; not restorable.
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["amp", "--execute", "do this", "--mode", "geppetto"],
|
||||
launcher: "amp",
|
||||
fallbackKind: "amp"
|
||||
) == nil
|
||||
)
|
||||
// A previously-resumed launch should have its `threads continue <id>`
|
||||
// preamble stripped so a re-resume doesn't re-prepend it.
|
||||
#expect(
|
||||
AgentLaunchSanitizer.sanitizedLaunchArguments(
|
||||
["amp", "threads", "continue", "T-old-id", "--mode", "geppetto"],
|
||||
launcher: "amp",
|
||||
fallbackKind: "amp"
|
||||
) == ["amp", "--mode", "geppetto"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import CMUXAgentLaunch
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite("HermesAgentHookConfig")
|
||||
struct HermesAgentHookConfigTests {
|
||||
@Test("Installs hooks into empty config")
|
||||
func installsHooksIntoEmptyConfig() {
|
||||
let events = [
|
||||
HermesAgentHookConfig.Event(name: "on_session_start", command: "sh -c 'cmux hooks hermes-agent session-start'"),
|
||||
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'", timeout: 120),
|
||||
]
|
||||
|
||||
let installed = HermesAgentHookConfig.installing(events: events, in: "")
|
||||
|
||||
#expect(installed.contains("# cmux hooks hermes-agent begin\nhooks:\n on_session_start:"))
|
||||
#expect(installed.contains(" - command: \"sh -c 'cmux hooks hermes-agent session-start'\""))
|
||||
#expect(installed.contains(" timeout: 120"))
|
||||
#expect(HermesAgentHookConfig.uninstalling(from: installed) == "")
|
||||
}
|
||||
|
||||
@Test("Preserves existing hook events without duplicating keys")
|
||||
func preservesExistingHookEventsWithoutDuplicatingKeys() {
|
||||
let existing = """
|
||||
model: anthropic/claude-sonnet-4.6
|
||||
hooks:
|
||||
pre_tool_call:
|
||||
- command: "echo user"
|
||||
timeout: 10
|
||||
post_llm_call:
|
||||
- command: "echo done"
|
||||
|
||||
"""
|
||||
let events = [
|
||||
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'", timeout: 120),
|
||||
HermesAgentHookConfig.Event(name: "on_session_end", command: "sh -c 'cmux hooks hermes-agent stop'"),
|
||||
]
|
||||
|
||||
let installed = HermesAgentHookConfig.installing(events: events, in: existing)
|
||||
|
||||
#expect(installed.components(separatedBy: "\n pre_tool_call:").count == 2)
|
||||
#expect(installed.contains(" pre_tool_call:\n # cmux hooks hermes-agent begin\n - command: \"sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'\""))
|
||||
#expect(installed.contains(" - command: \"echo user\""))
|
||||
#expect(installed.contains(" on_session_end:"))
|
||||
#expect(HermesAgentHookConfig.uninstalling(from: installed) == existing)
|
||||
}
|
||||
|
||||
@Test("Installs into multiple existing hook events without shifting later indexes")
|
||||
func installsIntoMultipleExistingEventsWithoutShiftingLaterIndexes() {
|
||||
let existing = """
|
||||
hooks:
|
||||
pre_tool_call:
|
||||
- command: "echo pre"
|
||||
post_tool_call:
|
||||
- command: "echo post"
|
||||
|
||||
"""
|
||||
let events = [
|
||||
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'"),
|
||||
HermesAgentHookConfig.Event(name: "post_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event post_tool_call'"),
|
||||
]
|
||||
|
||||
let installed = HermesAgentHookConfig.installing(events: events, in: existing)
|
||||
|
||||
#expect(installed.contains(" pre_tool_call:\n # cmux hooks hermes-agent begin"))
|
||||
#expect(installed.contains(" post_tool_call:\n # cmux hooks hermes-agent begin"))
|
||||
#expect(installed.contains(" - command: \"echo pre\""))
|
||||
#expect(installed.contains(" - command: \"echo post\""))
|
||||
#expect(HermesAgentHookConfig.uninstalling(from: installed) == existing)
|
||||
}
|
||||
|
||||
@Test("Installs into inline-empty hook events")
|
||||
func installsIntoInlineEmptyHookEvents() {
|
||||
let existing = """
|
||||
hooks:
|
||||
pre_tool_call: []
|
||||
post_tool_call: {} # intentionally empty
|
||||
|
||||
"""
|
||||
let events = [
|
||||
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'"),
|
||||
HermesAgentHookConfig.Event(name: "post_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event post_tool_call'"),
|
||||
]
|
||||
|
||||
let installed = HermesAgentHookConfig.installing(events: events, in: existing)
|
||||
|
||||
#expect(installed.contains(" pre_tool_call:\n # cmux hooks hermes-agent begin"))
|
||||
#expect(installed.contains(" post_tool_call:\n # cmux hooks hermes-agent begin"))
|
||||
#expect(!installed.contains("pre_tool_call: []\n # cmux hooks hermes-agent begin"))
|
||||
#expect(!installed.contains("post_tool_call: {} # intentionally empty\n # cmux hooks hermes-agent begin"))
|
||||
#expect(HermesAgentHookConfig.uninstalling(from: installed) == existing)
|
||||
}
|
||||
|
||||
@Test("Uninstalls inline-empty hooks root")
|
||||
func uninstallsInlineEmptyHooksRoot() {
|
||||
let existing = """
|
||||
model: anthropic/claude-sonnet-4.6
|
||||
hooks: [] # intentionally empty
|
||||
|
||||
"""
|
||||
let events = [
|
||||
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'"),
|
||||
HermesAgentHookConfig.Event(name: "post_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event post_tool_call'"),
|
||||
]
|
||||
|
||||
let installed = HermesAgentHookConfig.installing(events: events, in: existing)
|
||||
|
||||
#expect(installed.contains("hooks:\n # cmux hooks hermes-agent begin restore-line-base64:"))
|
||||
#expect(installed.contains(" pre_tool_call:"))
|
||||
#expect(HermesAgentHookConfig.uninstalling(from: installed) == existing)
|
||||
}
|
||||
|
||||
@Test("Allowlist install and uninstall only touches cmux commands")
|
||||
func allowlistInstallAndUninstallOnlyTouchesCmuxCommands() throws {
|
||||
let existing = """
|
||||
{
|
||||
"approvals": [
|
||||
{
|
||||
"command": "echo user",
|
||||
"event": "pre_tool_call"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)
|
||||
let events = [
|
||||
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'", timeout: 120),
|
||||
]
|
||||
|
||||
let installed = try HermesAgentHookAllowlist.installing(
|
||||
events: events,
|
||||
in: existing,
|
||||
approvedAt: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
let installedObject = try #require(JSONSerialization.jsonObject(with: installed) as? [String: Any])
|
||||
let approvals = try #require(installedObject["approvals"] as? [[String: Any]])
|
||||
#expect(approvals.count == 2)
|
||||
|
||||
let uninstalled = try HermesAgentHookAllowlist.uninstalling(events: events, from: installed)
|
||||
let uninstalledObject = try #require(JSONSerialization.jsonObject(with: uninstalled) as? [String: Any])
|
||||
let remaining = try #require(uninstalledObject["approvals"] as? [[String: Any]])
|
||||
#expect(remaining.count == 1)
|
||||
#expect(remaining.first?["command"] as? String == "echo user")
|
||||
}
|
||||
|
||||
@Test("Allowlist install preserves non-conforming approvals")
|
||||
func allowlistInstallPreservesNonConformingApprovals() throws {
|
||||
let existing = """
|
||||
{
|
||||
"approvals": [
|
||||
{
|
||||
"event": "pre_tool_call",
|
||||
"command": 12,
|
||||
"scope": "third-party"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)
|
||||
let events = [
|
||||
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'"),
|
||||
]
|
||||
|
||||
let installed = try HermesAgentHookAllowlist.installing(events: events, in: existing)
|
||||
let installedObject = try #require(JSONSerialization.jsonObject(with: installed) as? [String: Any])
|
||||
let approvals = try #require(installedObject["approvals"] as? [[String: Any]])
|
||||
|
||||
#expect(approvals.count == 2)
|
||||
#expect(approvals.contains { $0["scope"] as? String == "third-party" })
|
||||
#expect(approvals.contains { $0["command"] as? String == events[0].command })
|
||||
}
|
||||
|
||||
@Test("Allowlist install rejects non-object JSON roots")
|
||||
func allowlistInstallRejectsNonObjectJSONRoots() throws {
|
||||
let existing = #"[]"#.data(using: .utf8)
|
||||
let events = [
|
||||
HermesAgentHookConfig.Event(name: "pre_tool_call", command: "sh -c 'cmux hooks feed --source hermes-agent --event pre_tool_call'"),
|
||||
]
|
||||
|
||||
do {
|
||||
_ = try HermesAgentHookAllowlist.installing(events: events, in: existing)
|
||||
Issue.record("expected non-object allowlist JSON to throw")
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import CMUXAgentLaunch
|
||||
import Testing
|
||||
|
||||
@Suite("HermesAgentSessionResolver")
|
||||
struct HermesAgentSessionResolverTests {
|
||||
@Test("Uses HERMES_HOME when set")
|
||||
func usesHermesHomeWhenSet() {
|
||||
let env = ["HOME": "/Users/example", "HERMES_HOME": "/tmp/hermes profile"]
|
||||
|
||||
#expect(HermesAgentSessionResolver.hermesHome(env: env) == "/tmp/hermes profile")
|
||||
#expect(HermesAgentSessionResolver.configPath(env: env) == "/tmp/hermes profile/config.yaml")
|
||||
#expect(HermesAgentSessionResolver.stateDBPath(env: env) == "/tmp/hermes profile/state.db")
|
||||
#expect(HermesAgentSessionResolver.allowlistPath(env: env) == "/tmp/hermes profile/shell-hooks-allowlist.json")
|
||||
}
|
||||
|
||||
@Test("Falls back to HOME dot hermes")
|
||||
func fallsBackToHomeDotHermes() {
|
||||
let env = ["HOME": "/Users/example"]
|
||||
|
||||
#expect(HermesAgentSessionResolver.hermesHome(env: env) == "/Users/example/.hermes")
|
||||
#expect(HermesAgentSessionResolver.configPath(env: env) == "/Users/example/.hermes/config.yaml")
|
||||
}
|
||||
|
||||
@Test("Expands tilde with supplied HOME")
|
||||
func expandsTildeWithSuppliedHome() {
|
||||
let env = ["HOME": "/Users/example", "HERMES_HOME": "~/profiles/coder"]
|
||||
|
||||
#expect(HermesAgentSessionResolver.hermesHome(env: env) == "/Users/example/profiles/coder")
|
||||
}
|
||||
}
|
||||
@@ -38,4 +38,83 @@ struct RovoDevSessionResolverTests {
|
||||
#expect(RovoDevSessionResolver.inferredRovoDevSessionId(cwd: "", env: env) == nil)
|
||||
#expect(RovoDevSessionResolver.inferredRovoDevSessionId(cwd: root.path, env: env) == nil)
|
||||
}
|
||||
|
||||
@Test("Infers newest matching workspace session")
|
||||
func infersNewestMatchingWorkspaceSession() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-rovo-resolver-newest-\(UUID().uuidString)", isDirectory: true)
|
||||
let workspace = root.appendingPathComponent("repo", isDirectory: true)
|
||||
let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
try writeSession(
|
||||
sessionsRoot: sessionsRoot,
|
||||
sessionId: "older-session",
|
||||
workspacePath: workspace.path,
|
||||
metadataModified: Date(timeIntervalSince1970: 300),
|
||||
sessionContextModified: Date(timeIntervalSince1970: 100)
|
||||
)
|
||||
try writeSession(
|
||||
sessionsRoot: sessionsRoot,
|
||||
sessionId: "newer-session",
|
||||
workspacePath: workspace.path,
|
||||
metadataModified: Date(timeIntervalSince1970: 200),
|
||||
sessionContextModified: Date(timeIntervalSince1970: 400),
|
||||
workspaceKey: "workspacePath"
|
||||
)
|
||||
|
||||
let env = ["CMUX_ROVODEV_SESSIONS_DIR": sessionsRoot.path]
|
||||
#expect(RovoDevSessionResolver.inferredRovoDevSessionId(cwd: workspace.path, env: env) == "newer-session")
|
||||
}
|
||||
|
||||
@Test("Matches symlinked workspace paths")
|
||||
func matchesSymlinkedWorkspacePaths() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-rovo-resolver-symlink-\(UUID().uuidString)", isDirectory: true)
|
||||
let workspace = root.appendingPathComponent("repo-real", isDirectory: true)
|
||||
let workspaceLink = root.appendingPathComponent("repo-link", isDirectory: true)
|
||||
let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true)
|
||||
try FileManager.default.createSymbolicLink(at: workspaceLink, withDestinationURL: workspace)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
try writeSession(
|
||||
sessionsRoot: sessionsRoot,
|
||||
sessionId: "symlink-session",
|
||||
workspacePath: workspaceLink.path,
|
||||
metadataModified: Date(timeIntervalSince1970: 200),
|
||||
sessionContextModified: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
|
||||
let env = ["CMUX_ROVODEV_SESSIONS_DIR": sessionsRoot.path]
|
||||
#expect(RovoDevSessionResolver.inferredRovoDevSessionId(cwd: workspace.path, env: env) == "symlink-session")
|
||||
}
|
||||
|
||||
private func writeSession(
|
||||
sessionsRoot: URL,
|
||||
sessionId: String,
|
||||
workspacePath: String,
|
||||
metadataModified: Date,
|
||||
sessionContextModified: Date,
|
||||
workspaceKey: String = "workspace_path"
|
||||
) throws {
|
||||
let sessionURL = sessionsRoot.appendingPathComponent(sessionId, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: sessionURL, withIntermediateDirectories: true)
|
||||
let metadataURL = sessionURL.appendingPathComponent("metadata.json", isDirectory: false)
|
||||
let metadata = [
|
||||
"title": "Rovo Dev session",
|
||||
workspaceKey: workspacePath,
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: metadata)
|
||||
try data.write(to: metadataURL)
|
||||
try FileManager.default.setAttributes([.modificationDate: metadataModified], ofItemAtPath: metadataURL.path)
|
||||
|
||||
let sessionContextURL = sessionURL.appendingPathComponent("session_context.json", isDirectory: false)
|
||||
try Data(#"{"message_history":[]}"#.utf8).write(to: sessionContextURL)
|
||||
try FileManager.default.setAttributes(
|
||||
[.modificationDate: sessionContextModified],
|
||||
ofItemAtPath: sessionContextURL.path
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "CMUXAgentVault",
|
||||
platforms: [
|
||||
.macOS(.v13),
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "CMUXAgentVault",
|
||||
targets: ["CMUXAgentVault"]
|
||||
),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CMUXAgentVault",
|
||||
linkerSettings: [
|
||||
.linkedLibrary("sqlite3"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "CMUXAgentVaultTests",
|
||||
dependencies: ["CMUXAgentVault"],
|
||||
linkerSettings: [
|
||||
.linkedLibrary("sqlite3"),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
/// Indexed Hermes sessions do not carry cwd metadata and cannot be filtered by working directory.
|
||||
public struct HermesAgentIndexedSession: Equatable, Sendable {
|
||||
public let sessionId: String
|
||||
public let source: String
|
||||
public let title: String
|
||||
public let model: String?
|
||||
public let modified: Date
|
||||
public let preview: String?
|
||||
|
||||
public init(
|
||||
sessionId: String,
|
||||
source: String,
|
||||
title: String,
|
||||
model: String?,
|
||||
modified: Date,
|
||||
preview: String?
|
||||
) {
|
||||
self.sessionId = sessionId
|
||||
self.source = source
|
||||
self.title = title
|
||||
self.model = model
|
||||
self.modified = modified
|
||||
self.preview = preview
|
||||
}
|
||||
}
|
||||
|
||||
public struct HermesAgentIndexResult: Equatable, Sendable {
|
||||
public let sessions: [HermesAgentIndexedSession]
|
||||
public let errors: [String]
|
||||
|
||||
public init(sessions: [HermesAgentIndexedSession], errors: [String]) {
|
||||
self.sessions = sessions
|
||||
self.errors = errors
|
||||
}
|
||||
}
|
||||
|
||||
public struct HermesAgentTranscriptTurn: Equatable, Sendable {
|
||||
public let role: String
|
||||
public let content: String
|
||||
public let toolName: String?
|
||||
|
||||
public init(role: String, content: String, toolName: String?) {
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.toolName = toolName
|
||||
}
|
||||
}
|
||||
|
||||
public enum HermesAgentIndexError: Error, Equatable, Sendable {
|
||||
case missingDatabase
|
||||
case sqlite(String)
|
||||
}
|
||||
|
||||
private struct HermesAgentDatabaseSnapshot {
|
||||
let databaseURL: URL
|
||||
private let directoryURL: URL
|
||||
|
||||
init(databaseURL: URL, directoryURL: URL) {
|
||||
self.databaseURL = databaseURL
|
||||
self.directoryURL = directoryURL
|
||||
}
|
||||
|
||||
func remove() {
|
||||
try? FileManager.default.removeItem(at: directoryURL)
|
||||
}
|
||||
}
|
||||
|
||||
public enum HermesAgentIndex {
|
||||
private static let contentJSONPrefix = "\u{0}json:"
|
||||
// Keep this list aligned with source kinds resumeCommand knows how to relaunch.
|
||||
private static let knownSources = ["cli", "tui"]
|
||||
|
||||
public static func defaultStateDBPath(env: [String: String] = ProcessInfo.processInfo.environment) -> String {
|
||||
if let rawHome = normalized(env["HERMES_HOME"]) {
|
||||
return (expandedPath(rawHome, env: env) as NSString).appendingPathComponent("state.db")
|
||||
}
|
||||
let home = normalized(env["HOME"]) ?? NSHomeDirectory()
|
||||
return ((home as NSString).appendingPathComponent(".hermes") as NSString)
|
||||
.appendingPathComponent("state.db")
|
||||
}
|
||||
|
||||
/// Loads Hermes sessions from state.db. Hermes does not store cwd metadata, so any non-nil cwdFilter returns no sessions and no errors.
|
||||
public static func loadSessions(
|
||||
needle: String,
|
||||
cwdFilter: String?,
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
stateDBPath: String = Self.defaultStateDBPath()
|
||||
) -> HermesAgentIndexResult {
|
||||
guard limit > 0, offset >= 0 else {
|
||||
return HermesAgentIndexResult(sessions: [], errors: [])
|
||||
}
|
||||
let (_, overflow) = offset.addingReportingOverflow(limit)
|
||||
guard !overflow else {
|
||||
return HermesAgentIndexResult(sessions: [], errors: [])
|
||||
}
|
||||
guard cwdFilter == nil else {
|
||||
return HermesAgentIndexResult(sessions: [], errors: [])
|
||||
}
|
||||
|
||||
let snapshot: HermesAgentDatabaseSnapshot
|
||||
do {
|
||||
guard let madeSnapshot = try makeSnapshot(stateDBPath: stateDBPath, prefix: "cmux-hermes-agent-search") else {
|
||||
return HermesAgentIndexResult(sessions: [], errors: [])
|
||||
}
|
||||
snapshot = madeSnapshot
|
||||
} catch {
|
||||
return HermesAgentIndexResult(
|
||||
sessions: [],
|
||||
errors: ["Hermes Agent: cannot snapshot state.db (\(error.localizedDescription))"]
|
||||
)
|
||||
}
|
||||
defer { snapshot.remove() }
|
||||
|
||||
do {
|
||||
return try withDatabase(snapshot.databaseURL.path) { db in
|
||||
try loadSessions(db: db, needle: needle, offset: offset, limit: limit)
|
||||
}
|
||||
} catch {
|
||||
return HermesAgentIndexResult(
|
||||
sessions: [],
|
||||
errors: ["Hermes Agent: cannot read state.db (\(errorDescription(error)))"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public static func loadTranscript(
|
||||
sessionId: String,
|
||||
limit: Int,
|
||||
stateDBPath: String = Self.defaultStateDBPath()
|
||||
) throws -> [HermesAgentTranscriptTurn] {
|
||||
guard limit > 0 else { return [] }
|
||||
guard let snapshot = try makeSnapshot(stateDBPath: stateDBPath, prefix: "cmux-hermes-agent-preview") else {
|
||||
throw HermesAgentIndexError.missingDatabase
|
||||
}
|
||||
defer { snapshot.remove() }
|
||||
|
||||
return try withDatabase(snapshot.databaseURL.path) { db in
|
||||
try loadTranscript(db: db, sessionId: sessionId, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadSessions(
|
||||
db: OpaquePointer,
|
||||
needle: String,
|
||||
offset: Int,
|
||||
limit: Int
|
||||
) throws -> HermesAgentIndexResult {
|
||||
let trimmedNeedle = needle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let hasNeedle = !trimmedNeedle.isEmpty
|
||||
var sql = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.source,
|
||||
COALESCE(s.title, '') AS title,
|
||||
s.model,
|
||||
COALESCE(MAX(m.timestamp), s.ended_at, s.started_at) AS last_active,
|
||||
(
|
||||
SELECT m2.content
|
||||
FROM messages m2
|
||||
WHERE m2.session_id = s.id
|
||||
AND m2.role IN ('user', 'assistant')
|
||||
AND COALESCE(m2.content, '') <> ''
|
||||
ORDER BY m2.timestamp DESC, m2.id DESC
|
||||
LIMIT 1
|
||||
) AS preview
|
||||
FROM sessions s
|
||||
LEFT JOIN messages m ON m.session_id = s.id
|
||||
WHERE s.source IN (\(knownSources.map { "'\($0)'" }.joined(separator: ", ")))
|
||||
"""
|
||||
if hasNeedle {
|
||||
sql += """
|
||||
AND (
|
||||
LOWER(s.id) LIKE ?
|
||||
OR LOWER(COALESCE(s.title, '')) LIKE ?
|
||||
OR LOWER(COALESCE(s.model, '')) LIKE ?
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM messages needle_messages
|
||||
WHERE needle_messages.session_id = s.id
|
||||
AND (
|
||||
LOWER(COALESCE(needle_messages.content, '')) LIKE ?
|
||||
OR LOWER(COALESCE(needle_messages.tool_name, '')) LIKE ?
|
||||
)
|
||||
LIMIT 1
|
||||
)
|
||||
)
|
||||
"""
|
||||
}
|
||||
sql += """
|
||||
GROUP BY s.id
|
||||
ORDER BY last_active DESC
|
||||
LIMIT \(limit) OFFSET \(offset)
|
||||
"""
|
||||
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else {
|
||||
sqlite3_finalize(stmt)
|
||||
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "prepare failed")
|
||||
}
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
|
||||
if hasNeedle {
|
||||
let likePattern = "%\(trimmedNeedle)%"
|
||||
let destructor = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self)
|
||||
for index in 1...5 {
|
||||
guard sqlite3_bind_text(stmt, Int32(index), likePattern, -1, destructor) == SQLITE_OK else {
|
||||
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "bind failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sessions: [HermesAgentIndexedSession] = []
|
||||
var stepResult = sqlite3_step(stmt)
|
||||
while stepResult == SQLITE_ROW {
|
||||
let sessionId = sqliteText(stmt, 0) ?? ""
|
||||
guard !sessionId.isEmpty else {
|
||||
stepResult = sqlite3_step(stmt)
|
||||
continue
|
||||
}
|
||||
let source = sqliteText(stmt, 1) ?? "cli"
|
||||
let rawTitle = sqliteText(stmt, 2) ?? ""
|
||||
let model = sqliteText(stmt, 3)
|
||||
let modified = Date(timeIntervalSince1970: sqlite3_column_double(stmt, 4))
|
||||
let preview = decodedContentText(sqliteText(stmt, 5))
|
||||
let title = normalized(rawTitle) ?? firstLine(preview) ?? sessionId
|
||||
sessions.append(HermesAgentIndexedSession(
|
||||
sessionId: sessionId,
|
||||
source: source,
|
||||
title: title,
|
||||
model: model,
|
||||
modified: modified,
|
||||
preview: preview
|
||||
))
|
||||
stepResult = sqlite3_step(stmt)
|
||||
}
|
||||
|
||||
guard stepResult == SQLITE_DONE else {
|
||||
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "step failed")
|
||||
}
|
||||
return HermesAgentIndexResult(sessions: sessions, errors: [])
|
||||
}
|
||||
|
||||
private static func loadTranscript(
|
||||
db: OpaquePointer,
|
||||
sessionId: String,
|
||||
limit: Int
|
||||
) throws -> [HermesAgentTranscriptTurn] {
|
||||
let sql = """
|
||||
SELECT role, content, tool_name, tool_calls
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp, id
|
||||
LIMIT \(limit)
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else {
|
||||
sqlite3_finalize(stmt)
|
||||
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "prepare failed")
|
||||
}
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
|
||||
let destructor = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self)
|
||||
guard sqlite3_bind_text(stmt, 1, sessionId, -1, destructor) == SQLITE_OK else {
|
||||
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "bind failed")
|
||||
}
|
||||
|
||||
var turns: [HermesAgentTranscriptTurn] = []
|
||||
var stepResult = sqlite3_step(stmt)
|
||||
while stepResult == SQLITE_ROW {
|
||||
let role = sqliteText(stmt, 0) ?? "event"
|
||||
let content = decodedContentText(sqliteText(stmt, 1))
|
||||
let toolName = sqliteText(stmt, 2)
|
||||
let toolCalls = decodedContentText(sqliteText(stmt, 3))
|
||||
let text = [content, toolCalls]
|
||||
.compactMap { normalized($0) }
|
||||
.joined(separator: "\n\n")
|
||||
if !text.isEmpty {
|
||||
turns.append(HermesAgentTranscriptTurn(role: role, content: text, toolName: toolName))
|
||||
}
|
||||
stepResult = sqlite3_step(stmt)
|
||||
}
|
||||
|
||||
guard stepResult == SQLITE_DONE else {
|
||||
throw HermesAgentIndexError.sqlite(sqliteMessage(db) ?? "step failed")
|
||||
}
|
||||
return turns
|
||||
}
|
||||
|
||||
private static func makeSnapshot(stateDBPath: String, prefix: String) throws -> HermesAgentDatabaseSnapshot? {
|
||||
let fileManager = FileManager.default
|
||||
guard fileManager.fileExists(atPath: stateDBPath) else { return nil }
|
||||
|
||||
let snapshotDir = fileManager.temporaryDirectory
|
||||
.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true)
|
||||
try fileManager.createDirectory(at: snapshotDir, withIntermediateDirectories: true)
|
||||
|
||||
let snapshotDB = snapshotDir.appendingPathComponent("state.db", isDirectory: false)
|
||||
do {
|
||||
try fileManager.copyItem(atPath: stateDBPath, toPath: snapshotDB.path)
|
||||
for sidecar in ["-wal", "-shm"] {
|
||||
let source = stateDBPath + sidecar
|
||||
let destination = snapshotDB.path + sidecar
|
||||
if fileManager.fileExists(atPath: source) {
|
||||
try fileManager.copyItem(atPath: source, toPath: destination)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: snapshotDir)
|
||||
throw error
|
||||
}
|
||||
return HermesAgentDatabaseSnapshot(databaseURL: snapshotDB, directoryURL: snapshotDir)
|
||||
}
|
||||
|
||||
private static func withDatabase<T>(_ path: String, _ body: (OpaquePointer) throws -> T) throws -> T {
|
||||
var db: OpaquePointer?
|
||||
let openResult = sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil)
|
||||
guard openResult == SQLITE_OK, let db else {
|
||||
let message = sqliteMessage(db) ?? "open failed with code \(openResult)"
|
||||
sqlite3_close(db)
|
||||
throw HermesAgentIndexError.sqlite(message)
|
||||
}
|
||||
defer { sqlite3_close(db) }
|
||||
_ = sqlite3_busy_timeout(db, 50)
|
||||
return try body(db)
|
||||
}
|
||||
|
||||
private static func decodedContentText(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
if value.hasPrefix(contentJSONPrefix) {
|
||||
let payload = String(value.dropFirst(contentJSONPrefix.count))
|
||||
guard let data = payload.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) else {
|
||||
return value
|
||||
}
|
||||
return renderedContent(object)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private static func renderedContent(_ value: Any) -> String? {
|
||||
if let string = value as? String {
|
||||
return string
|
||||
}
|
||||
if let array = value as? [Any] {
|
||||
let parts = array.compactMap(renderedContent)
|
||||
return parts.isEmpty ? nil : parts.joined(separator: "\n\n")
|
||||
}
|
||||
guard let object = value as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
for key in ["text", "content", "output", "result", "message"] {
|
||||
if let value = object[key], let rendered = renderedContent(value) {
|
||||
return rendered
|
||||
}
|
||||
}
|
||||
guard JSONSerialization.isValidJSONObject(object),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func firstLine(_ value: String?) -> String? {
|
||||
guard let value = normalized(value) else { return nil }
|
||||
return value.components(separatedBy: .newlines).first.map { String($0.prefix(120)) }
|
||||
}
|
||||
|
||||
private static func sqliteText(_ stmt: OpaquePointer, _ index: Int32) -> String? {
|
||||
guard sqlite3_column_type(stmt, index) != SQLITE_NULL,
|
||||
let bytes = sqlite3_column_text(stmt, index) else {
|
||||
return nil
|
||||
}
|
||||
let count = Int(sqlite3_column_bytes(stmt, index))
|
||||
return String(data: Data(bytes: bytes, count: count), encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func sqliteMessage(_ db: OpaquePointer?) -> String? {
|
||||
guard let db, let cString = sqlite3_errmsg(db) else { return nil }
|
||||
return String(cString: cString)
|
||||
}
|
||||
|
||||
private static func errorDescription(_ error: Error) -> String {
|
||||
if let error = error as? HermesAgentIndexError {
|
||||
switch error {
|
||||
case .missingDatabase:
|
||||
return "missing database"
|
||||
case let .sqlite(message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func expandedPath(_ path: String, env: [String: String]) -> String {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed == "~" || trimmed.hasPrefix("~/") else {
|
||||
return NSString(string: trimmed).expandingTildeInPath
|
||||
}
|
||||
let home = normalized(env["HOME"]) ?? NSHomeDirectory()
|
||||
guard trimmed != "~" else { return home }
|
||||
return (home as NSString).appendingPathComponent(String(trimmed.dropFirst(2)))
|
||||
}
|
||||
}
|
||||
+84
-20
@@ -32,21 +32,52 @@ public struct RovoDevIndexResult: Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private struct RovoDevMetadata: Decodable {
|
||||
let title: String?
|
||||
let workspacePath: String?
|
||||
public enum RovoDevMetadataFields {
|
||||
public static let titleKeys: [String] = [
|
||||
"title",
|
||||
"name",
|
||||
"summary",
|
||||
]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case title
|
||||
case workspacePath = "workspace_path"
|
||||
case workspacePathCamel = "workspacePath"
|
||||
public static let workspacePathKeys: [String] = [
|
||||
"workspace_path",
|
||||
"workspacePath",
|
||||
"workspace",
|
||||
"cwd",
|
||||
"working_directory",
|
||||
"workingDirectory",
|
||||
"project_path",
|
||||
"projectPath",
|
||||
]
|
||||
|
||||
public static func title(from metadata: [String: Any]) -> String? {
|
||||
firstString(from: metadata, keys: titleKeys)
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
workspacePath = try container.decodeIfPresent(String.self, forKey: .workspacePath)
|
||||
?? container.decodeIfPresent(String.self, forKey: .workspacePathCamel)
|
||||
public static func workspacePath(from metadata: [String: Any]) -> String? {
|
||||
firstString(from: metadata, keys: workspacePathKeys)
|
||||
}
|
||||
|
||||
public static func firstString(from metadata: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
guard let value = metadata[key] as? String else { continue }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private enum RovoDevMetadataError: LocalizedError {
|
||||
case notObject
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notObject:
|
||||
return "metadata is not a JSON object"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +158,12 @@ public enum RovoDevIndex {
|
||||
continue
|
||||
}
|
||||
|
||||
let sessionContextURL = sessionURL.appendingPathComponent("session_context.json", isDirectory: false)
|
||||
candidates.append(RovoDevSessionCandidate(
|
||||
sessionId: sessionURL.lastPathComponent,
|
||||
metadataURL: metadataURL,
|
||||
sessionContextURL: sessionURL.appendingPathComponent("session_context.json", isDirectory: false),
|
||||
mtime: mtime
|
||||
sessionContextURL: sessionContextURL,
|
||||
mtime: max(mtime, Self.contentModificationDate(ofRegularFile: sessionContextURL) ?? mtime)
|
||||
))
|
||||
}
|
||||
candidates.sort { $0.mtime > $1.mtime }
|
||||
@@ -139,24 +171,26 @@ public enum RovoDevIndex {
|
||||
var matchedCount = 0
|
||||
var sessions: [RovoDevIndexedSession] = []
|
||||
sessions.reserveCapacity(limit)
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
for candidate in candidates {
|
||||
if Task.isCancelled { break }
|
||||
if matchedCount >= target { break }
|
||||
|
||||
let metadata: RovoDevMetadata
|
||||
let metadata: [String: Any]
|
||||
do {
|
||||
let data = try Data(contentsOf: candidate.metadataURL)
|
||||
metadata = try decoder.decode(RovoDevMetadata.self, from: data)
|
||||
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw RovoDevMetadataError.notObject
|
||||
}
|
||||
metadata = object
|
||||
} catch {
|
||||
errors.append("Rovo Dev: cannot read metadata \(candidate.metadataURL.path) (\(error.localizedDescription))")
|
||||
continue
|
||||
}
|
||||
|
||||
let title = metadata.title ?? ""
|
||||
let cwd = metadata.workspacePath
|
||||
if let cwdFilter, cwd != cwdFilter {
|
||||
let title = RovoDevMetadataFields.title(from: metadata) ?? ""
|
||||
let cwd = RovoDevMetadataFields.workspacePath(from: metadata)
|
||||
if !workspacePath(cwd, matchesFilter: cwdFilter) {
|
||||
continue
|
||||
}
|
||||
if !normalizedNeedle.isEmpty {
|
||||
@@ -188,4 +222,34 @@ public enum RovoDevIndex {
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
public static func contentModificationDate(ofRegularFile url: URL) -> Date? {
|
||||
guard let values = try? url.resourceValues(
|
||||
forKeys: [.contentModificationDateKey, .isRegularFileKey]
|
||||
),
|
||||
values.isRegularFile == true else {
|
||||
return nil
|
||||
}
|
||||
return values.contentModificationDate
|
||||
}
|
||||
|
||||
private static func workspacePath(_ workspacePath: String?, matchesFilter filter: String?) -> Bool {
|
||||
guard let filter else { return true }
|
||||
guard let workspace = Self.normalizedPath(workspacePath),
|
||||
let target = Self.normalizedPath(filter) else {
|
||||
return false
|
||||
}
|
||||
return workspace == target
|
||||
}
|
||||
|
||||
public static func normalizedPath(_ path: String?) -> String? {
|
||||
guard let trimmed = path?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return URL(fileURLWithPath: NSString(string: trimmed).expandingTildeInPath)
|
||||
.resolvingSymlinksInPath()
|
||||
.standardizedFileURL
|
||||
.path
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import CMUXAgentVault
|
||||
import Foundation
|
||||
import SQLite3
|
||||
import Testing
|
||||
|
||||
@Suite("HermesAgentIndex")
|
||||
struct HermesAgentIndexTests {
|
||||
@Test("Loads CLI and TUI sessions from state database")
|
||||
func loadsCliAndTUISessions() throws {
|
||||
let root = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let dbURL = root.appendingPathComponent("state.db", isDirectory: false)
|
||||
try makeHermesStateDB(at: dbURL)
|
||||
|
||||
try exec(dbURL, """
|
||||
INSERT INTO sessions (id, source, model, started_at, title)
|
||||
VALUES
|
||||
('old', 'cli', 'model-a', 10, 'Old session'),
|
||||
('new', 'tui', 'model-b', 20, NULL),
|
||||
('tool-only', 'tool', 'model-c', 30, 'Hidden tool session');
|
||||
INSERT INTO messages (session_id, role, content, timestamp)
|
||||
VALUES
|
||||
('old', 'user', 'older prompt', 11),
|
||||
('new', 'user', 'new prompt first line', 21),
|
||||
('new', 'assistant', 'new answer', 22),
|
||||
('tool-only', 'user', 'hidden', 31);
|
||||
""")
|
||||
|
||||
let result = HermesAgentIndex.loadSessions(
|
||||
needle: "",
|
||||
cwdFilter: nil,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
stateDBPath: dbURL.path
|
||||
)
|
||||
|
||||
#expect(result.errors.isEmpty)
|
||||
#expect(result.sessions.map(\.sessionId) == ["new", "old"])
|
||||
#expect(result.sessions.first?.source == "tui")
|
||||
#expect(result.sessions.first?.title == "new answer")
|
||||
#expect(result.sessions.first?.modified == Date(timeIntervalSince1970: 22))
|
||||
}
|
||||
|
||||
@Test("Searches messages and skips directory scoped requests")
|
||||
func searchesMessagesAndSkipsDirectoryScopedRequests() throws {
|
||||
let root = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let dbURL = root.appendingPathComponent("state.db", isDirectory: false)
|
||||
try makeHermesStateDB(at: dbURL)
|
||||
try exec(dbURL, """
|
||||
INSERT INTO sessions (id, source, model, started_at, title)
|
||||
VALUES ('session-a', 'cli', 'model-a', 10, 'General');
|
||||
INSERT INTO messages (session_id, role, content, timestamp)
|
||||
VALUES ('session-a', 'assistant', 'Needle text', 11);
|
||||
""")
|
||||
|
||||
let found = HermesAgentIndex.loadSessions(
|
||||
needle: "needle",
|
||||
cwdFilter: nil,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
stateDBPath: dbURL.path
|
||||
)
|
||||
let scoped = HermesAgentIndex.loadSessions(
|
||||
needle: "",
|
||||
cwdFilter: "/tmp/repo",
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
stateDBPath: dbURL.path
|
||||
)
|
||||
|
||||
#expect(found.sessions.map(\.sessionId) == ["session-a"])
|
||||
#expect(scoped.sessions.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Loads transcript and decodes Hermes JSON content")
|
||||
func loadsTranscriptAndDecodesHermesJSONContent() throws {
|
||||
let root = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let dbURL = root.appendingPathComponent("state.db", isDirectory: false)
|
||||
try makeHermesStateDB(at: dbURL)
|
||||
try exec(dbURL, """
|
||||
INSERT INTO sessions (id, source, model, started_at, title)
|
||||
VALUES ('session-a', 'cli', 'model-a', 10, 'General');
|
||||
INSERT INTO messages (session_id, role, content, tool_name, tool_calls, timestamp)
|
||||
VALUES
|
||||
('session-a', 'user', char(0) || 'json:[{"type":"text","text":"structured hello"}]', NULL, NULL, 11),
|
||||
('session-a', 'tool', 'ran command', 'terminal', '{"command":"pwd"}', 12);
|
||||
""")
|
||||
|
||||
let turns = try HermesAgentIndex.loadTranscript(
|
||||
sessionId: "session-a",
|
||||
limit: 10,
|
||||
stateDBPath: dbURL.path
|
||||
)
|
||||
|
||||
#expect(turns.count == 2)
|
||||
#expect(turns[0].role == "user")
|
||||
#expect(turns[0].content == "structured hello")
|
||||
#expect(turns[1].toolName == "terminal")
|
||||
#expect(turns[1].content.contains("ran command"))
|
||||
#expect(turns[1].content.contains("pwd"))
|
||||
}
|
||||
|
||||
private func temporaryDirectory() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-hermes-index-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
private func makeHermesStateDB(at url: URL) throws {
|
||||
try exec(url, """
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
model TEXT,
|
||||
model_config TEXT,
|
||||
system_prompt TEXT,
|
||||
parent_session_id TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
ended_at REAL,
|
||||
end_reason TEXT,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
tool_call_count INTEGER DEFAULT 0,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
cache_read_tokens INTEGER DEFAULT 0,
|
||||
cache_write_tokens INTEGER DEFAULT 0,
|
||||
reasoning_tokens INTEGER DEFAULT 0,
|
||||
billing_provider TEXT,
|
||||
billing_base_url TEXT,
|
||||
billing_mode TEXT,
|
||||
estimated_cost_usd REAL,
|
||||
actual_cost_usd REAL,
|
||||
cost_status TEXT,
|
||||
cost_source TEXT,
|
||||
pricing_version TEXT,
|
||||
title TEXT,
|
||||
api_call_count INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_name TEXT,
|
||||
timestamp REAL NOT NULL,
|
||||
token_count INTEGER,
|
||||
finish_reason TEXT,
|
||||
reasoning TEXT,
|
||||
reasoning_content TEXT,
|
||||
reasoning_details TEXT,
|
||||
codex_reasoning_items TEXT,
|
||||
codex_message_items TEXT
|
||||
);
|
||||
""")
|
||||
}
|
||||
|
||||
private func exec(_ dbURL: URL, _ sql: String) throws {
|
||||
var db: OpaquePointer?
|
||||
guard sqlite3_open(dbURL.path, &db) == SQLITE_OK, let db else {
|
||||
throw HermesAgentIndexError.sqlite("open failed")
|
||||
}
|
||||
defer { sqlite3_close(db) }
|
||||
|
||||
var error: UnsafeMutablePointer<Int8>?
|
||||
let result = sqlite3_exec(db, sql, nil, nil, &error)
|
||||
guard result == SQLITE_OK else {
|
||||
let message = error.map { String(cString: $0) } ?? "exec failed"
|
||||
sqlite3_free(error)
|
||||
throw HermesAgentIndexError.sqlite(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-6
@@ -1,4 +1,4 @@
|
||||
import CMUXRovoDevIndex
|
||||
import CMUXAgentVault
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@@ -30,6 +30,70 @@ struct RovoDevIndexTests {
|
||||
#expect(result.sessions.first?.sessionContextURL?.lastPathComponent == "session_context.json")
|
||||
}
|
||||
|
||||
@Test("Sorts by session activity and accepts workspacePath metadata")
|
||||
func sortsBySessionActivityAndAcceptsWorkspacePathMetadata() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { try? FileManager.default.removeItem(at: fixture.tempDir) }
|
||||
|
||||
try writeSession(
|
||||
in: fixture.sessionsRoot,
|
||||
id: "metadata-newer",
|
||||
title: "Metadata changed",
|
||||
cwd: "/tmp/rovo repo",
|
||||
modified: Date(timeIntervalSince1970: 300),
|
||||
sessionContextModified: Date(timeIntervalSince1970: 100)
|
||||
)
|
||||
try writeSession(
|
||||
in: fixture.sessionsRoot,
|
||||
id: "conversation-newer",
|
||||
title: "Conversation changed",
|
||||
cwd: "/tmp/rovo repo",
|
||||
modified: Date(timeIntervalSince1970: 200),
|
||||
sessionContextModified: Date(timeIntervalSince1970: 400),
|
||||
workspaceKey: "workspacePath"
|
||||
)
|
||||
|
||||
let result = RovoDevIndex.loadSessions(
|
||||
needle: "",
|
||||
cwdFilter: "/tmp/rovo repo",
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
sessionsRoot: fixture.sessionsRoot.path
|
||||
)
|
||||
|
||||
#expect(result.errors == [])
|
||||
#expect(result.sessions.map(\.sessionId) == ["conversation-newer", "metadata-newer"])
|
||||
}
|
||||
|
||||
@Test("Matches cwd filter through symlinks")
|
||||
func matchesCwdFilterThroughSymlinks() throws {
|
||||
let fixture = try makeFixture()
|
||||
let workspace = fixture.tempDir.appendingPathComponent("repo-real", isDirectory: true)
|
||||
let workspaceLink = fixture.tempDir.appendingPathComponent("repo-link", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true)
|
||||
try FileManager.default.createSymbolicLink(at: workspaceLink, withDestinationURL: workspace)
|
||||
defer { try? FileManager.default.removeItem(at: fixture.tempDir) }
|
||||
|
||||
try writeSession(
|
||||
in: fixture.sessionsRoot,
|
||||
id: "symlink-session",
|
||||
title: "Symlinked workspace",
|
||||
cwd: workspaceLink.path,
|
||||
modified: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
|
||||
let result = RovoDevIndex.loadSessions(
|
||||
needle: "",
|
||||
cwdFilter: workspace.path,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
sessionsRoot: fixture.sessionsRoot.path
|
||||
)
|
||||
|
||||
#expect(result.errors == [])
|
||||
#expect(result.sessions.map(\.sessionId) == ["symlink-session"])
|
||||
}
|
||||
|
||||
@Test("Reports malformed metadata")
|
||||
func reportsMalformedMetadata() throws {
|
||||
let fixture = try makeFixture()
|
||||
@@ -88,7 +152,7 @@ struct RovoDevIndexTests {
|
||||
|
||||
private func makeFixture() throws -> (tempDir: URL, sessionsRoot: URL) {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-rovodev-index-\(UUID().uuidString)", isDirectory: true)
|
||||
.appendingPathComponent("cmux-agent-vault-rovodev-index-\(UUID().uuidString)", isDirectory: true)
|
||||
let sessionsRoot = tempDir.appendingPathComponent("sessions", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: sessionsRoot, withIntermediateDirectories: true)
|
||||
return (tempDir, sessionsRoot)
|
||||
@@ -99,7 +163,9 @@ struct RovoDevIndexTests {
|
||||
id: String,
|
||||
title: String,
|
||||
cwd: String,
|
||||
modified: Date
|
||||
modified: Date,
|
||||
sessionContextModified: Date? = nil,
|
||||
workspaceKey: String = "workspace_path"
|
||||
) throws {
|
||||
let sessionDir = sessionsRoot.appendingPathComponent(id, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true)
|
||||
@@ -108,7 +174,7 @@ struct RovoDevIndexTests {
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: [
|
||||
"title": title,
|
||||
"workspace_path": cwd,
|
||||
workspaceKey: cwd,
|
||||
],
|
||||
options: [.sortedKeys]
|
||||
)
|
||||
@@ -118,7 +184,13 @@ struct RovoDevIndexTests {
|
||||
ofItemAtPath: metadataURL.path
|
||||
)
|
||||
|
||||
try Data(#"{"messages":[]}"#.utf8)
|
||||
.write(to: sessionDir.appendingPathComponent("session_context.json"))
|
||||
let sessionContextURL = sessionDir.appendingPathComponent("session_context.json")
|
||||
try Data(#"{"messages":[]}"#.utf8).write(to: sessionContextURL)
|
||||
if let sessionContextModified {
|
||||
try FileManager.default.setAttributes(
|
||||
[.modificationDate: sessionContextModified],
|
||||
ofItemAtPath: sessionContextURL.path
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -21,6 +21,28 @@ public enum PasteboardTextFidelity {
|
||||
(richTextHasLossySubstitution && richTextSubstitutionIsRelevant)
|
||||
}
|
||||
|
||||
public static func shouldInspectRichTextForPlainTextLoss(_ plainText: String) -> Bool {
|
||||
let metrics = textFidelityMetrics(plainText)
|
||||
return metrics.replacementCharacters > 0 || metrics.questionMarks >= 2
|
||||
}
|
||||
|
||||
public static func shouldPreferRichText(
|
||||
_ richText: String,
|
||||
overPlainText plainText: String
|
||||
) -> Bool {
|
||||
guard plainText != richText else { return false }
|
||||
|
||||
let plainMetrics = textFidelityMetrics(plainText)
|
||||
let richMetrics = textFidelityMetrics(richText)
|
||||
|
||||
let plainTextHasLossySubstitution =
|
||||
plainMetrics.replacementCharacters > richMetrics.replacementCharacters ||
|
||||
plainMetrics.questionMarks > richMetrics.questionMarks
|
||||
|
||||
return plainTextHasLossySubstitution &&
|
||||
richMetrics.nonASCII > plainMetrics.nonASCII
|
||||
}
|
||||
|
||||
public static func htmlHasNoVisibleText(_ html: String) -> Bool {
|
||||
var visibleCandidate = html.replacingOccurrences(
|
||||
of: "<!--[\\s\\S]*?-->",
|
||||
|
||||
+25
@@ -56,6 +56,31 @@ final class PasteboardTextFidelityTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testInspectsRichTextWhenPlainTextHasLossyMarkers() {
|
||||
XCTAssertTrue(PasteboardTextFidelity.shouldInspectRichTextForPlainTextLoss("??~"))
|
||||
XCTAssertTrue(PasteboardTextFidelity.shouldInspectRichTextForPlainTextLoss("\u{FFFD}~"))
|
||||
XCTAssertFalse(PasteboardTextFidelity.shouldInspectRichTextForPlainTextLoss("您好~"))
|
||||
XCTAssertFalse(PasteboardTextFidelity.shouldInspectRichTextForPlainTextLoss("Is this right?"))
|
||||
}
|
||||
|
||||
func testPrefersRichTextWhenPlainTextReplacesNonASCIIWithQuestionMarks() {
|
||||
XCTAssertTrue(
|
||||
PasteboardTextFidelity.shouldPreferRichText(
|
||||
"您好~",
|
||||
overPlainText: "??~"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testDoesNotPreferRichTextWhenQuestionMarkIsPreservedContent() {
|
||||
XCTAssertFalse(
|
||||
PasteboardTextFidelity.shouldPreferRichText(
|
||||
"what? 您好",
|
||||
overPlainText: "what?"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testHTMLWithOnlyHiddenBlocksHasNoVisibleText() {
|
||||
let html = """
|
||||
<!-- comment -->
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "CMUXRovoDevIndex",
|
||||
platforms: [
|
||||
.macOS(.v13),
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "CMUXRovoDevIndex",
|
||||
targets: ["CMUXRovoDevIndex"]
|
||||
),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CMUXRovoDevIndex"
|
||||
),
|
||||
.testTarget(
|
||||
name: "CMUXRovoDevIndexTests",
|
||||
dependencies: ["CMUXRovoDevIndex"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -7,9 +7,12 @@ import Foundation
|
||||
public enum WorkstreamSource: String, Codable, Sendable, CaseIterable, Equatable {
|
||||
case claude
|
||||
case codex
|
||||
case pi
|
||||
case amp
|
||||
case cursor
|
||||
case opencode
|
||||
case gemini
|
||||
case hermesAgent = "hermes-agent"
|
||||
case copilot
|
||||
case codebuddy
|
||||
case factory
|
||||
|
||||
@@ -157,8 +157,8 @@ For more info on how to configure cmux, [head over to our docs](https://cmux.com
|
||||
| ⌘ ⇧ R | Rename workspace |
|
||||
| ⌥ ⌘ E | Edit workspace description |
|
||||
| ⌘ B | Toggle sidebar |
|
||||
| ⌘ ⇧ E | Focus right sidebar |
|
||||
| ⌃ 1 / ⌃ 2 / ⌃ 3 | Switch Files / Sessions / Feed when the right sidebar is focused |
|
||||
| ⌥ ⌘ B | Toggle right sidebar |
|
||||
| ⌘ ⇧ E | Toggle right sidebar focus |
|
||||
|
||||
### Surfaces
|
||||
|
||||
@@ -209,8 +209,9 @@ Command palette navigation shortcuts, including ⌃ P, are also customizable and
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| ⌘ F | Find |
|
||||
| ⌘ G / ⌘ ⇧ G | Find next / previous |
|
||||
| ⌘ ⇧ F | Hide find bar |
|
||||
| ⌘ ⇧ F | Find in directory |
|
||||
| ⌘ G / ⌥ ⌘ G | Find next / previous |
|
||||
| ⌥ ⌘ ⇧ F | Hide find bar |
|
||||
| ⌘ E | Use selection for find |
|
||||
|
||||
### Terminal
|
||||
|
||||
+1287
-926
File diff suppressed because it is too large
Load Diff
+130
-18
@@ -85,16 +85,58 @@ case "${CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV:-}" in
|
||||
esac
|
||||
|
||||
should_preserve_claude_auth_selection_key() {
|
||||
[[ "$PRESERVE_CLAUDE_AUTH_SELECTION_ENV" == true ]] || return 1
|
||||
if [[ "$PRESERVE_CLAUDE_AUTH_SELECTION_ENV" == true ]]; then
|
||||
local configured_keys="${CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS:-}"
|
||||
if [[ -z "$configured_keys" ]]; then
|
||||
return 0
|
||||
fi
|
||||
configured_keys="${configured_keys//,/ }"
|
||||
local configured_key
|
||||
for configured_key in $configured_keys; do
|
||||
[[ "$configured_key" == "$1" ]] && return 0
|
||||
done
|
||||
# Key not in the explicit list: do NOT early-return — fall through to
|
||||
# the Vertex/Bedrock auto-preserve below. CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS
|
||||
# is an additive allow-list, not an exclusion list, so an operator
|
||||
# who opts in with a narrower key list still gets Vertex/Bedrock
|
||||
# selection signals preserved when those backends are active.
|
||||
fi
|
||||
|
||||
local configured_keys="${CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS:-}"
|
||||
[[ -n "$configured_keys" ]] || return 0
|
||||
# Auto-preserve Vertex/Bedrock auth selection so users with valid
|
||||
# CLAUDE_CODE_USE_VERTEX=1 / CLAUDE_CODE_USE_BEDROCK=1 setups in their
|
||||
# shell rc authenticate inside cmux the same way they do in plain
|
||||
# Terminal. Without this, the wrapper silently drops the selection
|
||||
# signal and claude reports "not logged in".
|
||||
# Refs: https://github.com/manaflow-ai/cmux/issues/3641 (Vertex)
|
||||
# https://github.com/manaflow-ai/cmux/issues/3638 (Bedrock)
|
||||
local vertex_on=false bedrock_on=false
|
||||
case "${CLAUDE_CODE_USE_VERTEX:-}" in
|
||||
1|true|TRUE|yes|YES) vertex_on=true ;;
|
||||
esac
|
||||
case "${CLAUDE_CODE_USE_BEDROCK:-}" in
|
||||
1|true|TRUE|yes|YES) bedrock_on=true ;;
|
||||
esac
|
||||
|
||||
case "$1" in
|
||||
CLAUDE_CODE_USE_VERTEX)
|
||||
[[ "$vertex_on" == true ]] && return 0
|
||||
;;
|
||||
CLAUDE_CODE_USE_BEDROCK)
|
||||
[[ "$bedrock_on" == true ]] && return 0
|
||||
;;
|
||||
ANTHROPIC_MODEL|ANTHROPIC_SMALL_FAST_MODEL)
|
||||
# Vertex and Bedrock require backend-specific full model ids
|
||||
# (e.g. "claude-sonnet-4-5@20250929" for Vertex,
|
||||
# "us.anthropic.claude-sonnet-4-5-20250929-v1:0" for Bedrock).
|
||||
# Preserve the user's selection only when one of those
|
||||
# backends is active; otherwise stale ids would leak into the
|
||||
# default Anthropic API path.
|
||||
if [[ "$vertex_on" == true || "$bedrock_on" == true ]]; then
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
configured_keys="${configured_keys//,/ }"
|
||||
local configured_key
|
||||
for configured_key in $configured_keys; do
|
||||
[[ "$configured_key" == "$1" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -139,6 +181,17 @@ if [[ -n "$CMUX_SURFACE_ID" ]]; then
|
||||
IN_CMUX=1
|
||||
fi
|
||||
|
||||
exec_real_claude_passthrough() {
|
||||
if [[ "$IN_CMUX" == "1" ]]; then
|
||||
local cmux_key
|
||||
for cmux_key in "${!CMUX_@}"; do
|
||||
unset "$cmux_key"
|
||||
done
|
||||
unset TERMINFO
|
||||
fi
|
||||
exec "$REAL_CLAUDE" "$@"
|
||||
}
|
||||
|
||||
if [[ "$IN_CMUX" == "0" || "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]] || ! cmux_socket_available; then
|
||||
# In cmux-launched shells, preserve old behavior and always clear nested
|
||||
# Claude session markers, even when we must pass through due to stale socket.
|
||||
@@ -147,7 +200,7 @@ if [[ "$IN_CMUX" == "0" || "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]] || ! cmux_soc
|
||||
fi
|
||||
clear_inherited_claude_auth_selection_env
|
||||
REAL_CLAUDE="$(find_real_claude)" || { echo "Error: claude not found in PATH" >&2; exit 127; }
|
||||
exec "$REAL_CLAUDE" "$@"
|
||||
exec_real_claude_passthrough "$@"
|
||||
fi
|
||||
|
||||
# Find real claude.
|
||||
@@ -261,13 +314,70 @@ encode_launch_argv() {
|
||||
} | base64 | tr -d '\n'
|
||||
}
|
||||
|
||||
# Pass through subcommands that don't support session/hook flags.
|
||||
case "${1:-}" in
|
||||
mcp|config|api-key|rc|remote-control)
|
||||
clear_inherited_claude_auth_selection_env
|
||||
exec "$REAL_CLAUDE" "$@"
|
||||
;;
|
||||
esac
|
||||
claude_option_consumes_value() {
|
||||
case "$1" in
|
||||
--add-dir|--agent|--agents|--allowedTools|--allowed-tools|\
|
||||
--append-system-prompt|--betas|--debug-file|--disallowedTools|\
|
||||
--disallowed-tools|--effort|--fallback-model|--file|\
|
||||
--input-format|--json-schema|--max-budget-usd|--mcp-config|\
|
||||
--model|-m|-n|--name|--output-format|--permission-mode|--plugin-dir|\
|
||||
--plugin-url|--remote-control-session-name-prefix|--setting-sources|\
|
||||
--settings|--system-prompt|--tools)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
claude_interactive_entry_flag() {
|
||||
case "$1" in
|
||||
--print|--print=*|-p|--resume|--resume=*|-r|--continue|-c|\
|
||||
--session-id|--session-id=*|--remote-control|--remote-control=*|\
|
||||
--from-pr|--from-pr=*|--worktree|--worktree=*|-w|-w=*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
should_inject_claude_hooks() {
|
||||
(( $# == 0 )) && return 0
|
||||
|
||||
local arg
|
||||
local skip_next=false
|
||||
for arg in "$@"; do
|
||||
if [[ "$skip_next" == true ]]; then
|
||||
skip_next=false
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--)
|
||||
return 0
|
||||
;;
|
||||
-*)
|
||||
if claude_interactive_entry_flag "$arg"; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$arg" != *=* ]] && claude_option_consumes_value "$arg"; then
|
||||
skip_next=true
|
||||
fi
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Only inject hooks for Claude session entrypoints. Other command-like
|
||||
# invocations pass through so new Claude subcommands do not need cmux changes.
|
||||
if ! should_inject_claude_hooks "$@"; then
|
||||
clear_inherited_claude_auth_selection_env
|
||||
exec_real_claude_passthrough "$@"
|
||||
fi
|
||||
|
||||
# Unset CLAUDECODE to avoid "nested session" detection — cmux terminals are
|
||||
# independent sessions even when the parent shell was launched from Claude Code.
|
||||
@@ -312,12 +422,14 @@ fi
|
||||
# - 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" and captures AskUserQuestion text for notifications (async status only)
|
||||
# - PreToolUse: rejects unsupported durable cron requests synchronously, then
|
||||
# clears "Needs input" and captures AskUserQuestion text for notifications
|
||||
# (async status only)
|
||||
# - PermissionRequest: cmux hooks feed. SYNC with a 125s timeout.
|
||||
# This is Claude Code's native blocking decision hook. It covers
|
||||
# permissions, ExitPlanMode, and AskUserQuestion without using
|
||||
# PreToolUse denial as a side channel.
|
||||
HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}'
|
||||
HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}'
|
||||
|
||||
if [[ "$SKIP_SESSION_ID" == true ]]; then
|
||||
exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@"
|
||||
|
||||
@@ -1,4 +1,71 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
final class MainWindowHostingView<Content: View>: NSHostingView<Content> {
|
||||
private let zeroSafeAreaLayoutGuide = NSLayoutGuide()
|
||||
|
||||
override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero }
|
||||
override var safeAreaRect: NSRect { bounds }
|
||||
override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide }
|
||||
|
||||
required init(rootView: Content) {
|
||||
super.init(rootView: rootView)
|
||||
addLayoutGuide(zeroSafeAreaLayoutGuide)
|
||||
NSLayoutConstraint.activate([
|
||||
zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor),
|
||||
zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
deinit {}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
class FirstMouseGatedHostingView<Content: View>: NSHostingView<Content> {
|
||||
override var intrinsicContentSize: NSSize {
|
||||
NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric)
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
if shouldCaptureInactiveFirstMouse(at: point) {
|
||||
return self
|
||||
}
|
||||
return super.hitTest(point)
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
||||
PaneFirstClickFocusSettings.isEnabled()
|
||||
}
|
||||
|
||||
func shouldCaptureInactiveFirstMouse(at point: NSPoint) -> Bool {
|
||||
let localPoint = superview.map { convert(point, from: $0) } ?? point
|
||||
return window?.isKeyWindow != true &&
|
||||
!PaneFirstClickFocusSettings.isEnabled() &&
|
||||
bounds.contains(localPoint)
|
||||
}
|
||||
}
|
||||
|
||||
final class FirstMouseGatedPassThroughHostingView<Content: View>: FirstMouseGatedHostingView<Content> {
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
shouldCaptureInactiveFirstMouse(at: point) ? self : nil
|
||||
}
|
||||
}
|
||||
|
||||
struct FirstMouseGatedHostingOverlay: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> FirstMouseGatedPassThroughHostingView<AnyView> {
|
||||
FirstMouseGatedPassThroughHostingView(rootView: AnyView(EmptyView()))
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: FirstMouseGatedPassThroughHostingView<AnyView>, context: Context) {
|
||||
nsView.rootView = AnyView(EmptyView())
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CmuxMainWindow: NSWindow {
|
||||
@@ -31,3 +98,32 @@ final class CmuxMainWindow: NSWindow {
|
||||
super.flagsChanged(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
extension CmuxMainWindow {
|
||||
private static let defaultContentSize = NSSize(width: 1_000, height: 700)
|
||||
|
||||
/// Returns an unpositioned content rect clamped to the visible display; callers own final placement.
|
||||
static func defaultContentRect(styleMask: NSWindow.StyleMask) -> NSRect {
|
||||
let unpositionedContentRect = NSRect(origin: .zero, size: defaultContentSize)
|
||||
guard let visibleFrame = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame else {
|
||||
return unpositionedContentRect
|
||||
}
|
||||
|
||||
let frameRect = NSWindow.frameRect(forContentRect: unpositionedContentRect, styleMask: styleMask)
|
||||
let clampedFrameRect = clampedFrame(frameRect, within: visibleFrame)
|
||||
return NSWindow.contentRect(forFrameRect: clampedFrameRect, styleMask: styleMask)
|
||||
}
|
||||
|
||||
private static func clampedFrame(_ frame: NSRect, within visibleFrame: NSRect) -> NSRect {
|
||||
guard visibleFrame.width > 0, visibleFrame.height > 0 else { return frame }
|
||||
|
||||
let width = min(max(frame.width, defaultContentSize.width), visibleFrame.width)
|
||||
let height = min(max(frame.height, defaultContentSize.height), visibleFrame.height)
|
||||
return NSRect(
|
||||
x: min(max(frame.minX, visibleFrame.minX), visibleFrame.maxX - width),
|
||||
y: min(max(frame.minY, visibleFrame.minY), visibleFrame.maxY - height),
|
||||
width: width,
|
||||
height: height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
@MainActor
|
||||
enum GhosttySurfaceConfigurationRefresh {
|
||||
nonisolated static let forceRefreshReason = "appDelegate.refreshAfterGhosttyConfigReload"
|
||||
|
||||
static func applyAfterAppConfigReload(
|
||||
to surface: ghostty_surface_t?,
|
||||
source: String,
|
||||
reloadSurfaceConfiguration: (ghostty_surface_t, Bool, String) -> Void,
|
||||
refreshHostBackground: () -> Void,
|
||||
forceRefresh: (String) -> Void
|
||||
) {
|
||||
if let surface {
|
||||
reloadSurfaceConfiguration(surface, true, source)
|
||||
}
|
||||
refreshHostBackground()
|
||||
forceRefresh(forceRefreshReason)
|
||||
}
|
||||
}
|
||||
@@ -153,15 +153,17 @@ final class MainWindowVisibilityController {
|
||||
dependencies.unhideApplication()
|
||||
trace("focus.unhide.end", reason: reason, windows: [window])
|
||||
}
|
||||
let effectiveActivation = activationRequiringKeyTransfer(activation, makeKey: makeKey)
|
||||
|
||||
if activationTiming == .beforeWindowOrdering {
|
||||
activate(activation)
|
||||
activate(effectiveActivation)
|
||||
}
|
||||
let shouldActivateBeforeWindowOrdering = activationTiming == .afterWindowOrdering &&
|
||||
deminiaturize &&
|
||||
dependencies.windowOperations.isMiniaturized(window)
|
||||
if shouldActivateBeforeWindowOrdering {
|
||||
trace("focus.activate.beforeMiniaturize.begin", reason: reason, windows: [window])
|
||||
activate(activation)
|
||||
activate(effectiveActivation)
|
||||
trace("focus.activate.beforeMiniaturize.end", reason: reason, windows: [window])
|
||||
}
|
||||
if deminiaturize, dependencies.windowOperations.isMiniaturized(window) {
|
||||
@@ -181,7 +183,7 @@ final class MainWindowVisibilityController {
|
||||
}
|
||||
if activationTiming == .afterWindowOrdering && !shouldActivateBeforeWindowOrdering {
|
||||
trace("focus.activate.begin", reason: reason, windows: [window])
|
||||
activate(activation)
|
||||
activate(effectiveActivation)
|
||||
trace("focus.activate.end", reason: reason, windows: [window])
|
||||
}
|
||||
log("focus", reason: reason, windows: [window])
|
||||
@@ -369,7 +371,9 @@ final class MainWindowVisibilityController {
|
||||
activation: Activation = .runningApplication([.activateAllWindows]),
|
||||
makeKey: Bool = true
|
||||
) -> NSWindow? {
|
||||
let windows = uniqueWindows(windows)
|
||||
let windows = uniqueWindows(windows).filter { window in
|
||||
makeKey || !dependencies.windowOperations.isMiniaturized(window)
|
||||
}
|
||||
guard !windows.isEmpty else {
|
||||
log("reveal.empty", reason: reason, windows: [])
|
||||
return nil
|
||||
@@ -383,8 +387,9 @@ final class MainWindowVisibilityController {
|
||||
for window in windows {
|
||||
dependencies.windowOperations.softShow(window)
|
||||
}
|
||||
let effectiveActivation = activationRequiringKeyTransfer(activation, makeKey: makeKey)
|
||||
trace("reveal.activate.begin", reason: reason, windows: windows)
|
||||
activate(activation)
|
||||
activate(effectiveActivation)
|
||||
trace("reveal.activate.end", reason: reason, windows: windows)
|
||||
|
||||
let focusWindow = resolvedPreferredFocusWindow(preferredWindow: preferredWindow, in: windows)
|
||||
@@ -459,6 +464,10 @@ final class MainWindowVisibilityController {
|
||||
}
|
||||
}
|
||||
|
||||
private func activationRequiringKeyTransfer(_ activation: Activation, makeKey: Bool) -> Activation {
|
||||
makeKey ? activation : .none
|
||||
}
|
||||
|
||||
private func uniqueWindows(_ windows: [NSWindow]) -> [NSWindow] {
|
||||
var result: [NSWindow] = []
|
||||
for window in windows where !result.contains(where: { $0 === window }) {
|
||||
|
||||
@@ -7,8 +7,7 @@ enum SessionSnapshotDebugBenchmark {
|
||||
includeScrollback: Bool,
|
||||
persist: Bool,
|
||||
buildSnapshot: (Bool) -> AppSessionSnapshot?,
|
||||
persistedGeometryData: (AppSessionSnapshot?) -> Data?,
|
||||
persistSnapshot: (AppSessionSnapshot?, Data?) -> Void
|
||||
persistSnapshot: (AppSessionSnapshot?) -> Void
|
||||
) -> [String: Any] {
|
||||
let buildStart = ProcessInfo.processInfo.systemUptime
|
||||
let snapshot = buildSnapshot(includeScrollback)
|
||||
@@ -16,9 +15,8 @@ enum SessionSnapshotDebugBenchmark {
|
||||
|
||||
var persistMs: Double?
|
||||
if persist {
|
||||
let geometryData = persistedGeometryData(snapshot)
|
||||
let persistStart = ProcessInfo.processInfo.systemUptime
|
||||
persistSnapshot(snapshot, geometryData)
|
||||
persistSnapshot(snapshot)
|
||||
persistMs = elapsedMs(since: persistStart)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
enum SettingsWindowPresenter {
|
||||
static let windowID = "settings"
|
||||
static let windowIdentifier = "cmux.settings"
|
||||
static let minimumSize = NSSize(width: 820, height: 540)
|
||||
private static let visibleAreaInset: CGFloat = 18
|
||||
|
||||
private static var openWindow: (@MainActor () -> Void)?
|
||||
private static var parentWindowProvider: (@MainActor () -> NSWindow?)?
|
||||
private static weak var settingsWindow: NSWindow?
|
||||
private static weak var observedParentWindow: NSWindow?
|
||||
private static weak var observedSettingsWindow: NSWindow?
|
||||
private static var parentCloseObserver: NSObjectProtocol?
|
||||
private static var pendingNavigationTarget: SettingsNavigationTarget?
|
||||
private static var pendingContentNavigationTarget: SettingsNavigationTarget?
|
||||
private static var shouldOpenWhenConfigured = false
|
||||
|
||||
static func configure(
|
||||
openWindow: @escaping @MainActor () -> Void,
|
||||
parentWindowProvider: @escaping @MainActor () -> NSWindow? = { nil }
|
||||
) {
|
||||
self.openWindow = openWindow
|
||||
self.parentWindowProvider = parentWindowProvider
|
||||
if let settingsWindow {
|
||||
attachToPreferredParent(settingsWindow)
|
||||
}
|
||||
if shouldOpenWhenConfigured {
|
||||
shouldOpenWhenConfigured = false
|
||||
openWindow()
|
||||
}
|
||||
}
|
||||
|
||||
static func configure(window: NSWindow) {
|
||||
settingsWindow = window
|
||||
window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier)
|
||||
window.isRestorable = false
|
||||
window.minSize = minimumSize
|
||||
window.contentMinSize = minimumSize
|
||||
clampToVisibleAreaIfNeeded(window)
|
||||
attachToPreferredParent(window)
|
||||
Task { @MainActor in
|
||||
guard settingsWindow === window else { return }
|
||||
focus(window)
|
||||
}
|
||||
}
|
||||
|
||||
static func show(
|
||||
navigationTarget: SettingsNavigationTarget? = nil,
|
||||
openWindowOverride: (@MainActor () -> Void)? = nil
|
||||
) {
|
||||
#if DEBUG
|
||||
cmuxDebugLog("settings.window.show path=swiftuiWindow")
|
||||
_ = CmuxUITestCapture.mutateJSONObjectIfConfigured(
|
||||
envKey: "CMUX_UI_TEST_SETTINGS_OPEN_CAPTURE_PATH"
|
||||
) { payload in
|
||||
payload["opened"] = true
|
||||
payload["target"] = navigationTarget?.rawValue ?? ""
|
||||
payload["used_open_window_override"] = openWindowOverride != nil
|
||||
}
|
||||
#endif
|
||||
pendingNavigationTarget = navigationTarget
|
||||
pendingContentNavigationTarget = navigationTarget
|
||||
|
||||
if let window = existingWindow() {
|
||||
pendingNavigationTarget = nil
|
||||
pendingContentNavigationTarget = nil
|
||||
focus(window)
|
||||
if let navigationTarget {
|
||||
SettingsNavigationRequest.post(navigationTarget)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let openWindowOverride {
|
||||
openWindowOverride()
|
||||
return
|
||||
}
|
||||
|
||||
guard let openWindow else {
|
||||
shouldOpenWhenConfigured = true
|
||||
return
|
||||
}
|
||||
openWindow()
|
||||
}
|
||||
|
||||
static func consumePendingNavigationTarget() -> SettingsNavigationTarget? {
|
||||
let target = pendingNavigationTarget
|
||||
pendingNavigationTarget = nil
|
||||
return target
|
||||
}
|
||||
|
||||
static func consumePendingContentNavigationTarget() -> SettingsNavigationTarget? {
|
||||
let target = pendingContentNavigationTarget
|
||||
pendingContentNavigationTarget = nil
|
||||
return target
|
||||
}
|
||||
|
||||
static func refocusIfVisible() {
|
||||
guard let window = existingWindow() else { return }
|
||||
focus(window)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func resetForTests() {
|
||||
if let settingsWindow {
|
||||
detachFromCurrentParent(settingsWindow)
|
||||
} else {
|
||||
removeParentCloseObserver()
|
||||
}
|
||||
openWindow = nil
|
||||
parentWindowProvider = nil
|
||||
settingsWindow = nil
|
||||
pendingNavigationTarget = nil
|
||||
pendingContentNavigationTarget = nil
|
||||
shouldOpenWhenConfigured = false
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func existingWindow() -> NSWindow? {
|
||||
if let settingsWindow, settingsWindow.isVisible || settingsWindow.isMiniaturized {
|
||||
return settingsWindow
|
||||
}
|
||||
return NSApp.windows.first {
|
||||
$0.identifier?.rawValue == windowIdentifier && ($0.isVisible || $0.isMiniaturized)
|
||||
}
|
||||
}
|
||||
|
||||
private static func focus(_ window: NSWindow) {
|
||||
if window.isMiniaturized {
|
||||
window.deminiaturize(nil)
|
||||
}
|
||||
clampToVisibleAreaIfNeeded(window)
|
||||
if let parentWindow = attachToPreferredParent(window) {
|
||||
orderParentBehindSettings(parentWindow)
|
||||
}
|
||||
NSRunningApplication.current.activate(options: [.activateAllWindows])
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.orderFrontRegardless()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func attachToPreferredParent(_ window: NSWindow) -> NSWindow? {
|
||||
guard let parentWindow = parentWindowProvider?(),
|
||||
parentWindow !== window else {
|
||||
detachFromCurrentParent(window)
|
||||
return nil
|
||||
}
|
||||
|
||||
if window.parent !== parentWindow {
|
||||
detachFromCurrentParent(window)
|
||||
parentWindow.addChildWindow(window, ordered: .above)
|
||||
}
|
||||
observeParentWillClose(parentWindow, settingsWindow: window)
|
||||
return parentWindow
|
||||
}
|
||||
|
||||
private static func detachFromCurrentParent(_ window: NSWindow) {
|
||||
removeParentCloseObserver()
|
||||
guard let parentWindow = window.parent else { return }
|
||||
parentWindow.removeChildWindow(window)
|
||||
}
|
||||
|
||||
private static func observeParentWillClose(_ parentWindow: NSWindow, settingsWindow: NSWindow) {
|
||||
guard observedParentWindow !== parentWindow || observedSettingsWindow !== settingsWindow else {
|
||||
return
|
||||
}
|
||||
|
||||
removeParentCloseObserver()
|
||||
observedParentWindow = parentWindow
|
||||
observedSettingsWindow = settingsWindow
|
||||
// Run synchronously for normal AppKit window-close notifications so
|
||||
// Settings detaches before AppKit orders out child windows.
|
||||
parentCloseObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.willCloseNotification,
|
||||
object: parentWindow,
|
||||
queue: nil
|
||||
) { [weak parentWindow, weak settingsWindow] _ in
|
||||
guard Thread.isMainThread else {
|
||||
assertionFailure("NSWindow.willCloseNotification should be delivered on the main thread")
|
||||
return
|
||||
}
|
||||
MainActor.assumeIsolated {
|
||||
detachFromClosingParent(parentWindow: parentWindow, settingsWindow: settingsWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func detachFromClosingParent(parentWindow: NSWindow?, settingsWindow: NSWindow?) {
|
||||
guard let settingsWindow, settingsWindow.parent === parentWindow else {
|
||||
removeParentCloseObserver()
|
||||
return
|
||||
}
|
||||
detachFromCurrentParent(settingsWindow)
|
||||
}
|
||||
|
||||
private static func removeParentCloseObserver() {
|
||||
if let parentCloseObserver {
|
||||
NotificationCenter.default.removeObserver(parentCloseObserver)
|
||||
}
|
||||
parentCloseObserver = nil
|
||||
observedParentWindow = nil
|
||||
observedSettingsWindow = nil
|
||||
}
|
||||
|
||||
private static func orderParentBehindSettings(_ window: NSWindow) {
|
||||
if window.isMiniaturized {
|
||||
window.deminiaturize(nil)
|
||||
}
|
||||
window.orderFront(nil)
|
||||
}
|
||||
|
||||
private static func clampToVisibleAreaIfNeeded(_ window: NSWindow) {
|
||||
guard let screen = window.screen ?? NSScreen.main else { return }
|
||||
var frame = window.frame
|
||||
let originalFrame = frame
|
||||
let visibleFrame = screen.visibleFrame
|
||||
let minimumFrameSize = NSSize(
|
||||
width: max(window.minSize.width, window.contentMinSize.width),
|
||||
height: max(window.minSize.height, window.contentMinSize.height)
|
||||
)
|
||||
let maxVisibleSize = NSSize(
|
||||
width: max(minimumFrameSize.width, visibleFrame.width - 2 * visibleAreaInset),
|
||||
height: max(minimumFrameSize.height, visibleFrame.height - 2 * visibleAreaInset)
|
||||
)
|
||||
frame.size.width = min(frame.size.width, maxVisibleSize.width)
|
||||
frame.size.height = min(frame.size.height, maxVisibleSize.height)
|
||||
let minX = visibleFrame.minX + visibleAreaInset
|
||||
let minY = visibleFrame.minY + visibleAreaInset
|
||||
let maxX = max(minX, visibleFrame.maxX - visibleAreaInset - frame.width)
|
||||
let maxY = max(minY, visibleFrame.maxY - visibleAreaInset - frame.height)
|
||||
frame.origin = NSPoint(
|
||||
x: min(max(frame.origin.x, minX), maxX),
|
||||
y: min(max(frame.origin.y, minY), maxY)
|
||||
)
|
||||
|
||||
guard frame != originalFrame else { return }
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,10 @@ func browserResponderHasMarkedText(_ responder: NSResponder?) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isBrowserReturnOrEnterKeyCode(_ keyCode: UInt16) -> Bool {
|
||||
keyCode == 36 || keyCode == 76
|
||||
}
|
||||
|
||||
func shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
||||
keyCode: UInt16,
|
||||
firstResponderIsBrowser: Bool,
|
||||
@@ -66,7 +70,7 @@ func shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
||||
) -> Bool {
|
||||
guard firstResponderIsBrowser else { return false }
|
||||
guard !firstResponderHasMarkedText else { return false }
|
||||
guard keyCode == 36 || keyCode == 76 else { return false }
|
||||
guard isBrowserReturnOrEnterKeyCode(keyCode) else { return false }
|
||||
// Keep browser Return forwarding narrow: only plain/Shift Return should be
|
||||
// treated as submit-intent. Command-modified Return is reserved for app shortcuts
|
||||
// like Toggle Pane Zoom (Cmd+Shift+Enter).
|
||||
@@ -81,10 +85,10 @@ func shouldDispatchBrowserArrowViaFirstResponderKeyDown(
|
||||
) -> Bool {
|
||||
guard firstResponderIsBrowser else { return false }
|
||||
guard !firstResponderHasMarkedText else { return false }
|
||||
guard keyCode == 125 || keyCode == 126 else { return false }
|
||||
guard (123...126).contains(keyCode) else { return false }
|
||||
|
||||
// Keep this narrow to avoid stealing app/browser shortcuts that layer onto
|
||||
// modified arrow keys. Plain up/down should always flow through keyDown so
|
||||
// modified arrow keys. Plain arrows should always flow through keyDown so
|
||||
// web content such as Google Docs receives the event directly.
|
||||
let normalizedFlags = flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
@@ -92,6 +96,27 @@ func shouldDispatchBrowserArrowViaFirstResponderKeyDown(
|
||||
return normalizedFlags.isEmpty
|
||||
}
|
||||
|
||||
func shouldDispatchCommandPaletteHorizontalArrowViaFirstResponderKeyDown(
|
||||
keyCode: UInt16,
|
||||
firstResponderIsCommandPaletteFieldEditor: Bool,
|
||||
firstResponderHasMarkedText: Bool = false,
|
||||
flags: NSEvent.ModifierFlags
|
||||
) -> Bool {
|
||||
guard firstResponderIsCommandPaletteFieldEditor else { return false }
|
||||
guard !firstResponderHasMarkedText else { return false }
|
||||
guard keyCode == 123 || keyCode == 124 else { return false }
|
||||
|
||||
let normalizedFlags = flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function, .capsLock])
|
||||
switch normalizedFlags {
|
||||
case [], [.shift], [.option], [.option, .shift], [.command], [.command, .shift]:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func shouldToggleMainWindowFullScreenForCommandControlFShortcut(
|
||||
flags: NSEvent.ModifierFlags,
|
||||
chars: String,
|
||||
@@ -384,7 +409,7 @@ func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
private enum BrowserFindCommandEquivalent {
|
||||
private enum BrowserFindCommandEquivalent: CaseIterable {
|
||||
case find
|
||||
case findInDirectory
|
||||
case findNext
|
||||
@@ -392,6 +417,17 @@ private enum BrowserFindCommandEquivalent {
|
||||
case hideFind
|
||||
case useSelection
|
||||
|
||||
var action: KeyboardShortcutSettings.Action {
|
||||
switch self {
|
||||
case .find: return .find
|
||||
case .findInDirectory: return .findInDirectory
|
||||
case .findNext: return .findNext
|
||||
case .findPrevious: return .findPrevious
|
||||
case .hideFind: return .hideFind
|
||||
case .useSelection: return .useSelectionForFind
|
||||
}
|
||||
}
|
||||
|
||||
var keepsCmuxBrowserFindBarOwnershipWhenVisible: Bool {
|
||||
switch self {
|
||||
case .find, .findNext, .findPrevious, .hideFind:
|
||||
@@ -421,52 +457,12 @@ func cmuxIsLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? {
|
||||
let flags = event.modifierFlags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function, .capsLock])
|
||||
|
||||
let normalizedChars = KeyboardLayout.normalizedCharacters(for: event).lowercased()
|
||||
let hasSingleASCIIShortcutChar =
|
||||
normalizedChars.count == 1 && normalizedChars.allSatisfy(\.isASCII)
|
||||
let producedAnyASCIIShortcutChar = normalizedChars.contains(where: \.isASCII)
|
||||
func matches(_ chars: String, keyCode: UInt16) -> Bool {
|
||||
if hasSingleASCIIShortcutChar {
|
||||
return normalizedChars == chars
|
||||
}
|
||||
if !producedAnyASCIIShortcutChar {
|
||||
return event.keyCode == keyCode
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
switch flags {
|
||||
case [.command]:
|
||||
if matches("e", keyCode: 14) { // kVK_ANSI_E
|
||||
return .useSelection
|
||||
}
|
||||
if matches("f", keyCode: 3) { // kVK_ANSI_F
|
||||
return .find
|
||||
}
|
||||
if matches("g", keyCode: 5) { // kVK_ANSI_G
|
||||
return .findNext
|
||||
}
|
||||
return nil
|
||||
case [.command, .shift]:
|
||||
if matches("f", keyCode: 3) { // kVK_ANSI_F
|
||||
return .findInDirectory
|
||||
}
|
||||
if matches("g", keyCode: 5) { // kVK_ANSI_G
|
||||
return .findPrevious
|
||||
}
|
||||
return nil
|
||||
case [.command, .option, .shift]:
|
||||
if matches("f", keyCode: 3) { // kVK_ANSI_F
|
||||
return .hideFind
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
private func browserFindCommandEquivalent(
|
||||
for event: NSEvent,
|
||||
shortcutForAction: (KeyboardShortcutSettings.Action) -> StoredShortcut = KeyboardShortcutSettings.shortcut(for:)
|
||||
) -> BrowserFindCommandEquivalent? {
|
||||
BrowserFindCommandEquivalent.allCases.first { command in
|
||||
shortcutForAction(command.action).matches(event: event)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,18 +109,54 @@ enum TerminalScrollBarSettings {
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentSessionAutoResumeSettings {
|
||||
static let autoResumeAgentSessionsKey = "terminal.autoResumeAgentSessions"
|
||||
static let defaultAutoResumeAgentSessions = true
|
||||
static let didChangeNotification = Notification.Name("cmux.agentSessionAutoResumeSettingsDidChange")
|
||||
|
||||
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
guard defaults.object(forKey: autoResumeAgentSessionsKey) != nil else {
|
||||
return defaultAutoResumeAgentSessions
|
||||
}
|
||||
return defaults.bool(forKey: autoResumeAgentSessionsKey)
|
||||
}
|
||||
|
||||
static func setEnabled(
|
||||
_ enabled: Bool,
|
||||
defaults: UserDefaults = .standard,
|
||||
notificationCenter: NotificationCenter = .default
|
||||
) {
|
||||
let wasEnabled = isEnabled(defaults: defaults)
|
||||
defaults.set(enabled, forKey: autoResumeAgentSessionsKey)
|
||||
if wasEnabled != enabled {
|
||||
notifyDidChange(notificationCenter: notificationCenter)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func reset(
|
||||
defaults: UserDefaults = .standard,
|
||||
notificationCenter: NotificationCenter = .default
|
||||
) -> Bool {
|
||||
let wasEnabled = isEnabled(defaults: defaults)
|
||||
defaults.removeObject(forKey: autoResumeAgentSessionsKey)
|
||||
let didChange = wasEnabled != isEnabled(defaults: defaults)
|
||||
if didChange {
|
||||
notifyDidChange(notificationCenter: notificationCenter)
|
||||
}
|
||||
return didChange
|
||||
}
|
||||
|
||||
static func notifyDidChange(notificationCenter: NotificationCenter = .default) {
|
||||
notificationCenter.post(name: didChangeNotification, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
enum RightSidebarBetaFeatureSettings {
|
||||
static let feedEnabledKey = "rightSidebar.beta.feed.enabled"
|
||||
static let dockEnabledKey = "rightSidebar.beta.dock.enabled"
|
||||
|
||||
static let defaultFeedEnabled = false
|
||||
static let defaultDockEnabled = false
|
||||
|
||||
nonisolated static func isFeedEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
guard defaults.object(forKey: feedEnabledKey) != nil else { return defaultFeedEnabled }
|
||||
return defaults.bool(forKey: feedEnabledKey)
|
||||
}
|
||||
|
||||
nonisolated static func isDockEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
guard defaults.object(forKey: dockEnabledKey) != nil else { return defaultDockEnabled }
|
||||
return defaults.bool(forKey: dockEnabledKey)
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class CmuxSSHURLProcessLauncher {
|
||||
static let shared = CmuxSSHURLProcessLauncher()
|
||||
|
||||
private var processes: [Int32: Process] = [:]
|
||||
private var isShuttingDown = false
|
||||
|
||||
private init() {}
|
||||
|
||||
func terminateAll() {
|
||||
isShuttingDown = true
|
||||
for process in processes.values where process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
processes.removeAll()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func start(request: CmuxSSHURLRequest, preferredWindow: NSWindow?) -> Bool {
|
||||
let cliURL = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux")
|
||||
guard let cliURL,
|
||||
FileManager.default.isExecutableFile(atPath: cliURL.path) else {
|
||||
presentLaunchFailure(
|
||||
summary: String(
|
||||
localized: "dialog.sshURL.launchFailed.missingCLI",
|
||||
defaultValue: "The bundled cmux CLI is missing from this app build."
|
||||
),
|
||||
output: "",
|
||||
preferredWindow: preferredWindow
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
let socketPath = resolvedSocketPath()
|
||||
let process = Process()
|
||||
process.executableURL = cliURL
|
||||
process.arguments = ["--socket", socketPath] + request.cliArguments
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["CMUX_SOCKET_PATH"] = socketPath
|
||||
environment["CMUX_BUNDLED_CLI_PATH"] = cliURL.path
|
||||
environment.removeValue(forKey: "CMUX_SOCKET")
|
||||
process.environment = environment
|
||||
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
let outputCollector = ProcessOutputCollector(stdout: outputPipe, stderr: errorPipe)
|
||||
outputCollector.start()
|
||||
process.terminationHandler = { [weak preferredWindow] terminatedProcess in
|
||||
let output = outputCollector.finish()
|
||||
let processIdentifier = terminatedProcess.processIdentifier
|
||||
let terminationStatus = terminatedProcess.terminationStatus
|
||||
Task { @MainActor in
|
||||
Self.shared.processes.removeValue(forKey: processIdentifier)
|
||||
guard terminationStatus != 0, !Self.shared.isShuttingDown else { return }
|
||||
let format = String(
|
||||
localized: "dialog.sshURL.launchFailed.exit",
|
||||
defaultValue: "cmux ssh exited with status %d."
|
||||
)
|
||||
Self.shared.presentLaunchFailure(
|
||||
summary: String(format: format, Int(terminationStatus)),
|
||||
output: output,
|
||||
preferredWindow: preferredWindow
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
processes[process.processIdentifier] = process
|
||||
#if DEBUG
|
||||
cmuxDebugLog("sshURL.launchCLI pid=\(process.processIdentifier) socket=\(socketPath) targetLength=\(request.destination.count)")
|
||||
#endif
|
||||
return true
|
||||
} catch {
|
||||
outputCollector.cancel()
|
||||
presentLaunchFailure(
|
||||
summary: String(
|
||||
localized: "dialog.sshURL.launchFailed.launch",
|
||||
defaultValue: "cmux ssh could not be launched."
|
||||
),
|
||||
output: error.localizedDescription,
|
||||
preferredWindow: preferredWindow
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedSocketPath() -> String {
|
||||
TerminalController.shared.activeSocketPath(
|
||||
preferredPath: SocketControlSettings.socketPath()
|
||||
)
|
||||
}
|
||||
|
||||
private func presentLaunchFailure(summary: String, output: String, preferredWindow: NSWindow?) {
|
||||
let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let limitedOutput = String(trimmedOutput.prefix(2000))
|
||||
let informativeText = limitedOutput.isEmpty
|
||||
? summary
|
||||
: "\(summary)\n\n\(limitedOutput)"
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = String(
|
||||
localized: "dialog.sshURL.launchFailed.title",
|
||||
defaultValue: "Couldn't Open SSH Link"
|
||||
)
|
||||
alert.informativeText = informativeText
|
||||
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
|
||||
if let preferredWindow {
|
||||
alert.beginSheetModal(for: preferredWindow, completionHandler: nil)
|
||||
} else if let window = NSApp.keyWindow ?? NSApp.mainWindow {
|
||||
alert.beginSheetModal(for: window, completionHandler: nil)
|
||||
} else {
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class CmuxSSHURLConfirmationGate: NSObject {
|
||||
weak var connectButton: NSButton?
|
||||
|
||||
deinit {}
|
||||
|
||||
@objc func checkboxChanged(_ sender: NSButton) {
|
||||
connectButton?.isEnabled = sender.state == .on
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate {
|
||||
func deferInitialMainWindowBootstrapForExternalConfirmation() {
|
||||
guard !didAttemptStartupSessionRestore, !didHandleExplicitOpenIntentAtStartup else { return }
|
||||
shouldDeferInitialMainWindowBootstrapForExternalConfirmation = true
|
||||
}
|
||||
|
||||
func resumeInitialMainWindowBootstrapAfterExternalConfirmation(debugSource: String) {
|
||||
guard shouldDeferInitialMainWindowBootstrapForExternalConfirmation else { return }
|
||||
shouldDeferInitialMainWindowBootstrapForExternalConfirmation = false
|
||||
scheduleInitialMainWindowBootstrap(debugSource: debugSource)
|
||||
}
|
||||
|
||||
func bootstrapInitialMainWindowAfterAcceptedExternalOpen(debugSource: String) {
|
||||
shouldDeferInitialMainWindowBootstrapForExternalConfirmation = false
|
||||
_ = bootstrapInitialMainWindowIfNeeded(debugSource: debugSource)
|
||||
}
|
||||
|
||||
func claimAuthCallbackURLSchemes() {
|
||||
// Pin the current build's callback scheme so auth and SSH deeplinks
|
||||
// route back to this app instead of an unrelated LaunchServices entry.
|
||||
let bundleURL = Bundle.main.bundleURL
|
||||
NSWorkspace.shared.setDefaultApplication(
|
||||
at: bundleURL,
|
||||
toOpenURLsWithScheme: AuthEnvironment.callbackScheme
|
||||
) { _ in }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handleCmuxSSHURLs(from urls: [URL]) -> Bool {
|
||||
var sshURLRequests: [CmuxSSHURLRequest] = []
|
||||
var sshURLParseErrors: [CmuxSSHURLParseError] = []
|
||||
for url in urls {
|
||||
switch CmuxSSHURLRequest.parse(url) {
|
||||
case .success(.some(let request)):
|
||||
sshURLRequests.append(request)
|
||||
case .success(nil):
|
||||
break
|
||||
case .failure(let error):
|
||||
sshURLParseErrors.append(error)
|
||||
}
|
||||
}
|
||||
let sshURLIntentCount = sshURLRequests.count + sshURLParseErrors.count
|
||||
guard sshURLIntentCount > 0 else { return false }
|
||||
|
||||
if urls.count > 1 || sshURLIntentCount > 1 {
|
||||
showCmuxSSHURLParseError(.multipleLinks)
|
||||
} else {
|
||||
for error in sshURLParseErrors {
|
||||
showCmuxSSHURLParseError(error)
|
||||
}
|
||||
if let request = sshURLRequests.first {
|
||||
handleCmuxSSHURLRequest(request)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func handleCmuxSSHURLRequest(_ request: CmuxSSHURLRequest) {
|
||||
#if DEBUG
|
||||
let target = request.originalURL.host ?? request.originalURL.path
|
||||
cmuxDebugLog("sshURL.prompt target=\(target) destinationLength=\(request.destination.count) hasPort=\(request.port != nil)")
|
||||
#endif
|
||||
|
||||
deferInitialMainWindowBootstrapForExternalConfirmation()
|
||||
guard confirmCmuxSSHURLRequest(request) else {
|
||||
resumeInitialMainWindowBootstrapAfterExternalConfirmation(debugSource: "sshURL.cancelled")
|
||||
#if DEBUG
|
||||
cmuxDebugLog("sshURL.cancelled")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
prepareForExplicitOpenIntentAtStartup()
|
||||
bootstrapInitialMainWindowAfterAcceptedExternalOpen(debugSource: "sshURL.confirmed")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
_ = CmuxSSHURLProcessLauncher.shared.start(
|
||||
request: request,
|
||||
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow
|
||||
)
|
||||
}
|
||||
|
||||
private func confirmCmuxSSHURLRequest(_ request: CmuxSSHURLRequest) -> Bool {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = String(
|
||||
localized: "dialog.sshURL.title",
|
||||
defaultValue: "Open an SSH Connection From an External Link?"
|
||||
)
|
||||
let scheme = request.originalURL.scheme ?? AuthEnvironment.callbackScheme
|
||||
alert.informativeText = String(
|
||||
format: String(
|
||||
localized: "dialog.sshURL.message",
|
||||
defaultValue: "A %@:// link is asking cmux to open an SSH workspace. cmux cannot verify which website or app opened this link.\n\nSSH may use your local SSH config, keys, agent settings, ProxyCommand, LocalCommand, and forwarding rules for this target. External links cannot supply arbitrary SSH options. Only continue if you trust this SSH target."
|
||||
),
|
||||
scheme
|
||||
)
|
||||
|
||||
let cancelTitle = String(localized: "dialog.sshURL.cancel", defaultValue: "Cancel")
|
||||
let runTitle = String(localized: "dialog.sshURL.run", defaultValue: "Connect")
|
||||
alert.addButton(withTitle: cancelTitle)
|
||||
alert.addButton(withTitle: runTitle)
|
||||
|
||||
let cancelButton = alert.buttons[0]
|
||||
cancelButton.keyEquivalent = "\r"
|
||||
if alert.buttons.count > 1 {
|
||||
let connectButton = alert.buttons[1]
|
||||
connectButton.keyEquivalent = ""
|
||||
connectButton.isEnabled = false
|
||||
if #available(macOS 11.0, *) {
|
||||
connectButton.hasDestructiveAction = true
|
||||
}
|
||||
}
|
||||
|
||||
let gate = CmuxSSHURLConfirmationGate()
|
||||
if alert.buttons.count > 1 {
|
||||
gate.connectButton = alert.buttons[1]
|
||||
}
|
||||
alert.accessoryView = cmuxSSHURLAccessoryView(request: request, gate: gate)
|
||||
|
||||
let response: NSApplication.ModalResponse = withExtendedLifetime(gate) {
|
||||
alert.runModal()
|
||||
}
|
||||
return response == .alertSecondButtonReturn
|
||||
}
|
||||
|
||||
private func cmuxSSHURLAccessoryView(
|
||||
request: CmuxSSHURLRequest,
|
||||
gate: CmuxSSHURLConfirmationGate
|
||||
) -> NSView {
|
||||
let stack = NSStackView()
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 8
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let targetLabel = NSTextField(labelWithString: String(
|
||||
format: String(localized: "dialog.sshURL.targetLabel", defaultValue: "SSH target: %@"),
|
||||
request.displayTarget
|
||||
))
|
||||
targetLabel.lineBreakMode = .byTruncatingMiddle
|
||||
targetLabel.maximumNumberOfLines = 1
|
||||
|
||||
let commandLabel = NSTextField(labelWithString: String(
|
||||
localized: "dialog.sshURL.commandLabel",
|
||||
defaultValue: "Command preview:"
|
||||
))
|
||||
commandLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold)
|
||||
|
||||
let socketPath = CmuxSSHURLProcessLauncher.shared.resolvedSocketPath()
|
||||
let commandScrollView = cmuxSSHURLTextPreview(request.cliPreview(socketPath: socketPath), height: 80)
|
||||
|
||||
stack.addArrangedSubview(targetLabel)
|
||||
stack.addArrangedSubview(commandLabel)
|
||||
stack.addArrangedSubview(commandScrollView)
|
||||
|
||||
let checkbox = NSButton(
|
||||
checkboxWithTitle: String(
|
||||
localized: "dialog.sshURL.checkbox",
|
||||
defaultValue: "I trust this SSH target and want cmux to connect."
|
||||
),
|
||||
target: gate,
|
||||
action: #selector(CmuxSSHURLConfirmationGate.checkboxChanged(_:))
|
||||
)
|
||||
checkbox.lineBreakMode = .byWordWrapping
|
||||
stack.addArrangedSubview(checkbox)
|
||||
|
||||
let container = NSView(frame: NSRect(x: 0, y: 0, width: 560, height: 156))
|
||||
container.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
targetLabel.widthAnchor.constraint(equalTo: container.widthAnchor),
|
||||
commandScrollView.widthAnchor.constraint(equalTo: container.widthAnchor),
|
||||
checkbox.widthAnchor.constraint(equalTo: container.widthAnchor)
|
||||
])
|
||||
return container
|
||||
}
|
||||
|
||||
private func cmuxSSHURLTextPreview(_ text: String, height: CGFloat) -> NSScrollView {
|
||||
let textView = NSTextView(frame: .zero)
|
||||
textView.string = text
|
||||
textView.isEditable = false
|
||||
textView.isSelectable = true
|
||||
textView.drawsBackground = true
|
||||
textView.backgroundColor = NSColor.textBackgroundColor
|
||||
textView.textColor = NSColor.labelColor
|
||||
textView.font = NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
|
||||
textView.textContainerInset = NSSize(width: 8, height: 8)
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.isVerticallyResizable = true
|
||||
textView.textContainer?.widthTracksTextView = true
|
||||
|
||||
let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 560, height: height))
|
||||
scrollView.borderType = .bezelBorder
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = false
|
||||
scrollView.documentView = textView
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.heightAnchor.constraint(equalToConstant: height)
|
||||
])
|
||||
return scrollView
|
||||
}
|
||||
|
||||
private func showCmuxSSHURLParseError(_ error: CmuxSSHURLParseError) {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = String(
|
||||
localized: "dialog.sshURL.blocked.title",
|
||||
defaultValue: "cmux SSH Link Blocked"
|
||||
)
|
||||
alert.informativeText = cmuxSSHURLParseErrorMessage(error)
|
||||
alert.addButton(withTitle: String(localized: "dialog.sshURL.blocked.ok", defaultValue: "OK"))
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
private func cmuxSSHURLParseErrorMessage(_ error: CmuxSSHURLParseError) -> String {
|
||||
switch error {
|
||||
case .missingDestination:
|
||||
return String(
|
||||
localized: "dialog.sshURL.error.missingDestination",
|
||||
defaultValue: "The link did not include an SSH host."
|
||||
)
|
||||
case .destinationTooLong(let maxLength):
|
||||
return String(
|
||||
format: String(localized: "dialog.sshURL.error.destinationTooLong", defaultValue: "The SSH target is too long. The maximum length is %lld characters."),
|
||||
maxLength
|
||||
)
|
||||
case .destinationContainsUnsafeCharacters:
|
||||
return String(
|
||||
localized: "dialog.sshURL.error.destinationContainsUnsafeCharacters",
|
||||
defaultValue: "The SSH host or user contains unsupported or hidden characters, so cmux refused to use it."
|
||||
)
|
||||
case .destinationStartsWithDash:
|
||||
return String(
|
||||
localized: "dialog.sshURL.error.destinationStartsWithDash",
|
||||
defaultValue: "The SSH host or user cannot start with a dash."
|
||||
)
|
||||
case .titleTooLong(let maxLength):
|
||||
return String(
|
||||
format: String(localized: "dialog.sshURL.error.titleTooLong", defaultValue: "The workspace title is too long. The maximum length is %lld characters."),
|
||||
maxLength
|
||||
)
|
||||
case .titleContainsUnsafeCharacters:
|
||||
return String(
|
||||
localized: "dialog.sshURL.error.titleContainsControlCharacters",
|
||||
defaultValue: "The workspace title contains hidden control or formatting characters, so cmux refused to use it."
|
||||
)
|
||||
case .invalidPort:
|
||||
return String(
|
||||
localized: "dialog.sshURL.error.invalidPort",
|
||||
defaultValue: "The SSH port must be between 1 and 65535."
|
||||
)
|
||||
case .invalidIntegerParameter(let parameter):
|
||||
return String(
|
||||
format: String(localized: "dialog.sshURL.error.invalidIntegerParameter", defaultValue: "The SSH link included an invalid integer value for parameter: %@"),
|
||||
parameter
|
||||
)
|
||||
case .invalidHostKeyPolicy(let parameter):
|
||||
return String(
|
||||
format: String(localized: "dialog.sshURL.error.invalidHostKeyPolicy", defaultValue: "The SSH link included an invalid host key policy for parameter: %@"),
|
||||
parameter
|
||||
)
|
||||
case .invalidBooleanParameter(let parameter):
|
||||
return String(
|
||||
format: String(localized: "dialog.sshURL.error.invalidBooleanParameter", defaultValue: "The SSH link included an invalid boolean value for parameter: %@"),
|
||||
parameter
|
||||
)
|
||||
case .conflictingDestinationParameters:
|
||||
return String(
|
||||
localized: "dialog.sshURL.error.conflictingDestinationParameters",
|
||||
defaultValue: "The link included conflicting SSH target fields."
|
||||
)
|
||||
case .conflictingTitleParameters:
|
||||
return String(
|
||||
localized: "dialog.sshURL.error.conflictingTitleParameters",
|
||||
defaultValue: "The link included both title and name. Use only one workspace title field."
|
||||
)
|
||||
case .duplicateParameter(let parameter):
|
||||
return String(
|
||||
format: String(localized: "dialog.sshURL.error.duplicateParameter", defaultValue: "The SSH link repeated a parameter: %@"),
|
||||
parameter
|
||||
)
|
||||
case .unsupportedParameter(let parameter):
|
||||
return String(
|
||||
format: String(localized: "dialog.sshURL.error.unsupportedParameter", defaultValue: "The SSH link included an unsupported parameter: %@"),
|
||||
parameter
|
||||
)
|
||||
case .multipleLinks:
|
||||
return String(
|
||||
localized: "dialog.sshURL.error.multipleLinks",
|
||||
defaultValue: "Only one SSH link can be opened at a time."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+597
-245
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,14 @@ final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn {
|
||||
}
|
||||
|
||||
func setDockTile(_ dockTile: NSDockTile?) {
|
||||
Self.performOnMain { [self] in
|
||||
setDockTileOnMain(dockTile)
|
||||
}
|
||||
}
|
||||
|
||||
private func setDockTileOnMain(_ dockTile: NSDockTile?) {
|
||||
Self.assertMainQueue()
|
||||
|
||||
if let iconChangeObserver {
|
||||
DistributedNotificationCenter.default().removeObserver(iconChangeObserver)
|
||||
self.iconChangeObserver = nil
|
||||
@@ -53,7 +61,7 @@ final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn {
|
||||
iconChangeObserver = DistributedNotificationCenter.default().addObserver(
|
||||
forName: cmuxAppIconDidChangeNotification,
|
||||
object: nil,
|
||||
queue: nil
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.updateDockTile(dockTile)
|
||||
@@ -91,6 +99,8 @@ final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn {
|
||||
}
|
||||
|
||||
private func updateDockTile(_ dockTile: NSDockTile) {
|
||||
Self.assertMainQueue()
|
||||
|
||||
let mode = DockTileAppIconMode(defaultsValue: appDefaults?.string(forKey: cmuxAppIconModeKey))
|
||||
let isDarkAppearance = NSApp?.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
||||
guard let appBundleURL else {
|
||||
@@ -117,6 +127,20 @@ final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn {
|
||||
dockTile.showIcon(icon)
|
||||
}
|
||||
|
||||
private static func performOnMain(_ work: @escaping () -> Void) {
|
||||
if Thread.isMainThread {
|
||||
work()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: work)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate static func assertMainQueue() {
|
||||
#if DEBUG
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Determine the enclosing app bundle for the dock tile plugin bundle.
|
||||
static func appBundleURL(for pluginBundleURL: URL) -> URL? {
|
||||
var url = pluginBundleURL
|
||||
@@ -137,20 +161,20 @@ final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn {
|
||||
|
||||
private extension NSDockTile {
|
||||
func showDefaultAppIcon() {
|
||||
DispatchQueue.main.async {
|
||||
self.contentView = nil
|
||||
self.display()
|
||||
}
|
||||
CmuxDockTilePlugin.assertMainQueue()
|
||||
|
||||
contentView = nil
|
||||
display()
|
||||
}
|
||||
|
||||
func showIcon(_ newIcon: NSImage) {
|
||||
DispatchQueue.main.async {
|
||||
let iconView = NSImageView(frame: CGRect(origin: .zero, size: self.size))
|
||||
iconView.wantsLayer = true
|
||||
iconView.image = newIcon
|
||||
self.contentView = iconView
|
||||
self.display()
|
||||
}
|
||||
CmuxDockTilePlugin.assertMainQueue()
|
||||
|
||||
let iconView = NSImageView(frame: CGRect(origin: .zero, size: size))
|
||||
iconView.wantsLayer = true
|
||||
iconView.image = newIcon
|
||||
contentView = iconView
|
||||
display()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ enum AuthCallbackRouter {
|
||||
|
||||
private static func isAllowedScheme(_ scheme: String?) -> Bool {
|
||||
guard let normalized = scheme?.lowercased() else { return false }
|
||||
if normalized == "cmux" || normalized == "cmux-dev" {
|
||||
if normalized == "cmux" || normalized == "cmux-nightly" || normalized == "cmux-dev" {
|
||||
return true
|
||||
}
|
||||
// Honor the runtime override so any AuthEnvironment.callbackScheme
|
||||
|
||||
@@ -13,13 +13,14 @@ enum AuthEnvironment {
|
||||
!overridden.isEmpty {
|
||||
return overridden
|
||||
}
|
||||
// Match the Info.plist CFBundleURLSchemes $(CMUX_AUTH_CALLBACK_SCHEME)
|
||||
// expansion: cmux-dev in Debug builds, cmux in Release. Without this
|
||||
// Debug split, beginSignIn() would start an ASWebAuthenticationSession
|
||||
// listening on "cmux" while the OS routes cmux-dev:// → this app.
|
||||
#if DEBUG
|
||||
// Debug and tagged dev builds register cmux-dev:// so they can coexist
|
||||
// with the installed stable app.
|
||||
return "cmux-dev"
|
||||
#else
|
||||
if Bundle.main.bundleIdentifier == "com.cmuxterm.app.nightly" {
|
||||
return "cmux-nightly"
|
||||
}
|
||||
return "cmux"
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -2,11 +2,17 @@ import AppKit
|
||||
import AuthenticationServices
|
||||
import CMUXAuthCore
|
||||
import Foundation
|
||||
import os
|
||||
import StackAuth
|
||||
#if canImport(Security)
|
||||
import Security
|
||||
#endif
|
||||
|
||||
nonisolated private let authLogger = Logger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "com.cmuxterm.app",
|
||||
category: "AuthManager"
|
||||
)
|
||||
|
||||
private final class AuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding {
|
||||
static let shared = AuthPresentationContext()
|
||||
|
||||
@@ -145,6 +151,7 @@ final class AuthManager: ObservableObject {
|
||||
private let tokenStore: any StackAuthTokenStoreProtocol
|
||||
private let settingsStore: AuthSettingsStore
|
||||
private let urlOpener: (URL) -> Void
|
||||
private let usesSystemWebAuthenticationSession: () -> Bool
|
||||
|
||||
/// Resolves when the on-launch session restoration finishes (success or failure).
|
||||
/// Any probe that needs a definitive `isAuthenticated` value must `await` this
|
||||
@@ -157,12 +164,16 @@ final class AuthManager: ObservableObject {
|
||||
client: (any AuthClientProtocol)? = nil,
|
||||
tokenStore: any StackAuthTokenStoreProtocol = KeychainStackTokenStore(),
|
||||
settingsStore: AuthSettingsStore = AuthSettingsStore(),
|
||||
urlOpener: ((URL) -> Void)? = nil
|
||||
urlOpener: ((URL) -> Void)? = nil,
|
||||
usesSystemWebAuthenticationSession: (() -> Bool)? = nil
|
||||
) {
|
||||
self.tokenStore = tokenStore
|
||||
self.settingsStore = settingsStore
|
||||
self.client = client ?? Self.makeDefaultClient(tokenStore: tokenStore)
|
||||
self.urlOpener = urlOpener ?? Self.defaultURLOpener
|
||||
self.usesSystemWebAuthenticationSession = usesSystemWebAuthenticationSession ?? {
|
||||
Self.shouldUseSystemWebAuthenticationSession()
|
||||
}
|
||||
let cachedUser = settingsStore.cachedUser()
|
||||
self.currentUser = cachedUser
|
||||
self.selectedTeamID = settingsStore.selectedTeamID
|
||||
@@ -182,15 +193,52 @@ final class AuthManager: ObservableObject {
|
||||
await bootstrapTask.value
|
||||
}
|
||||
|
||||
private var loginPollTask: Task<Void, Never>?
|
||||
private var webAuthSession: ASWebAuthenticationSession?
|
||||
|
||||
func beginSignIn() {
|
||||
loginPollTask?.cancel()
|
||||
beginSignIn(keepLoadingForExternalBrowser: false)
|
||||
}
|
||||
|
||||
private func beginSignIn(keepLoadingForExternalBrowser: Bool) {
|
||||
webAuthSession?.cancel()
|
||||
webAuthSession = nil
|
||||
isLoading = true
|
||||
|
||||
if usesSystemWebAuthenticationSession() {
|
||||
isLoading = true
|
||||
beginSystemWebAuthenticationSession()
|
||||
return
|
||||
}
|
||||
|
||||
let signInURL = AuthEnvironment.signInURL()
|
||||
authLog("beginSignIn: opening external browser url=\(signInURL.absoluteString)")
|
||||
urlOpener(signInURL)
|
||||
|
||||
if keepLoadingForExternalBrowser {
|
||||
isLoading = true
|
||||
} else if isLoading {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldUseSystemWebAuthenticationSession(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> Bool {
|
||||
let value = environment["CMUX_AUTH_USE_ASWEB_AUTH_SESSION"]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
switch value {
|
||||
case "0", "false", "no":
|
||||
return false
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
default:
|
||||
// ASWebAuthenticationSession scopes callbacks to the initiating
|
||||
// session even when parallel debug apps share the same URL scheme.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func beginSystemWebAuthenticationSession() {
|
||||
let signInURL = AuthEnvironment.signInURL()
|
||||
let callbackScheme = AuthEnvironment.callbackScheme
|
||||
|
||||
@@ -205,14 +253,14 @@ final class AuthManager: ObservableObject {
|
||||
self.webAuthSession = nil
|
||||
}
|
||||
if let error {
|
||||
NSLog("auth.webauth failed: %@", "\(error)")
|
||||
authLogger.error("ASWebAuthenticationSession failed: \(String(describing: error), privacy: .private)")
|
||||
return
|
||||
}
|
||||
guard let callbackURL else { return }
|
||||
do {
|
||||
try await self.handleCallbackURL(callbackURL)
|
||||
} catch {
|
||||
NSLog("auth.webauth callback failed: %@", "\(error)")
|
||||
authLogger.error("ASWebAuthenticationSession callback failed: \(String(describing: error), privacy: .private)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,20 +270,24 @@ final class AuthManager: ObservableObject {
|
||||
if session.start() {
|
||||
webAuthSession = session
|
||||
} else {
|
||||
NSLog("auth.webauth: session.start() returned false")
|
||||
authLogger.warning("ASWebAuthenticationSession start returned false")
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the ASWebAuthenticationSession popup and awaits the user's
|
||||
/// Starts sign-in and awaits the user's
|
||||
/// completion by observing isAuthenticated AND isLoading. Resolves when
|
||||
/// authenticated, when the sign-in attempt settles unsuccessfully (popup
|
||||
/// dismissed/cancelled/error), or when the deadline elapses. No polling
|
||||
/// authenticated, when the sign-in attempt settles unsuccessfully
|
||||
/// (popup dismissed/cancelled/error), or when the deadline elapses. No polling
|
||||
/// — the $isAuthenticated / $isLoading AsyncPublishers drive the wait.
|
||||
func beginSignInAndAwait(timeout: TimeInterval) async -> Bool {
|
||||
if isAuthenticated { return true }
|
||||
beginSignIn()
|
||||
return await waitForSignInSettled(timeout: timeout)
|
||||
beginSignIn(keepLoadingForExternalBrowser: true)
|
||||
let signedIn = await waitForSignInSettled(timeout: timeout)
|
||||
if !signedIn && isLoading && !isAuthenticated {
|
||||
isLoading = false
|
||||
}
|
||||
return signedIn
|
||||
}
|
||||
|
||||
/// Signs out and awaits the state to flip. signOut() is already async and
|
||||
@@ -398,6 +450,11 @@ final class AuthManager: ObservableObject {
|
||||
throw AuthManagerError.invalidCallback
|
||||
}
|
||||
|
||||
// System web-auth callbacks arrive from the session completion handler,
|
||||
// so there is no active presentation to cancel on this shared callback path.
|
||||
// The external-browser path never creates an ASWebAuthenticationSession.
|
||||
webAuthSession = nil
|
||||
lastKnownAccessToken = nil
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
@@ -406,6 +463,7 @@ final class AuthManager: ObservableObject {
|
||||
refreshToken: payload.refreshToken
|
||||
)
|
||||
try await refreshSession()
|
||||
lastKnownAccessToken = payload.accessToken
|
||||
didCompleteBrowserSignIn = true
|
||||
}
|
||||
|
||||
@@ -573,6 +631,10 @@ final class AuthManager: ObservableObject {
|
||||
}
|
||||
|
||||
func signOut() async {
|
||||
webAuthSession?.cancel()
|
||||
webAuthSession = nil
|
||||
isLoading = false
|
||||
lastKnownAccessToken = nil
|
||||
try? await client.signOut()
|
||||
await tokenStore.clear()
|
||||
clearSessionState(clearSelectedTeam: true)
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class BackgroundWorkspacePrimeCoordinator {
|
||||
private nonisolated enum PrimeCompletionReason: String {
|
||||
case alreadyCleared = "already_cleared"
|
||||
case cancelled
|
||||
case surfaceReady = "surface_ready"
|
||||
case timeout
|
||||
case workspaceRemoved = "workspace_removed"
|
||||
}
|
||||
|
||||
private nonisolated enum PrimeState {
|
||||
case pending
|
||||
case completed(reason: PrimeCompletionReason)
|
||||
}
|
||||
|
||||
private nonisolated enum Policy {
|
||||
static let timeoutSeconds: TimeInterval = 2.0
|
||||
}
|
||||
|
||||
private nonisolated final class Waiter: @unchecked Sendable {
|
||||
// Cancellation handlers cannot await an actor hop; this lock keeps continuation
|
||||
// and cleanup state synchronous across task cancellation and readiness callbacks.
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<PrimeCompletionReason, Never>?
|
||||
private var cleanupActions: [() -> Void] = []
|
||||
private var resolvedReason: PrimeCompletionReason?
|
||||
|
||||
var isResolved: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return resolvedReason != nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
finish(reason: .cancelled)
|
||||
}
|
||||
|
||||
func start(continuation: CheckedContinuation<PrimeCompletionReason, Never>) {
|
||||
let reason: PrimeCompletionReason?
|
||||
lock.lock()
|
||||
reason = resolvedReason
|
||||
if reason == nil {
|
||||
self.continuation = continuation
|
||||
}
|
||||
lock.unlock()
|
||||
if let reason {
|
||||
continuation.resume(returning: reason)
|
||||
}
|
||||
}
|
||||
|
||||
func addObserver(_ observer: NSObjectProtocol) {
|
||||
addCleanup { NotificationCenter.default.removeObserver(observer) }
|
||||
}
|
||||
|
||||
func addCancellable(_ cancellable: AnyCancellable) {
|
||||
addCleanup { cancellable.cancel() }
|
||||
}
|
||||
|
||||
func addTask(_ task: Task<Void, Never>) {
|
||||
addCleanup { task.cancel() }
|
||||
}
|
||||
|
||||
func finish(reason: PrimeCompletionReason) {
|
||||
let drained: (CheckedContinuation<PrimeCompletionReason, Never>?, [() -> Void])?
|
||||
lock.lock()
|
||||
if resolvedReason == nil {
|
||||
resolvedReason = reason
|
||||
drained = (continuation, cleanupActions)
|
||||
continuation = nil
|
||||
cleanupActions.removeAll()
|
||||
} else {
|
||||
drained = nil
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
guard let (continuation, cleanupActions) = drained else { return }
|
||||
cleanupActions.forEach { $0() }
|
||||
continuation?.resume(returning: reason)
|
||||
}
|
||||
|
||||
private func addCleanup(_ action: @escaping () -> Void) {
|
||||
lock.lock()
|
||||
guard resolvedReason == nil else {
|
||||
lock.unlock()
|
||||
action()
|
||||
return
|
||||
}
|
||||
cleanupActions.append(action)
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Explicit for the required_deinit lint; per-prime resources live on Waiter.
|
||||
}
|
||||
|
||||
func taskKey(for tabManager: TabManager) -> [String] {
|
||||
tabManager.pendingBackgroundWorkspaceLoadIds
|
||||
.map(\.uuidString)
|
||||
.sorted()
|
||||
}
|
||||
|
||||
func primePendingBackgroundWorkspaces(tabManager: TabManager) async {
|
||||
while !Task.isCancelled {
|
||||
let workspaceIds = tabManager.pendingBackgroundWorkspaceLoadIds.sorted { $0.uuidString < $1.uuidString }
|
||||
guard !workspaceIds.isEmpty else { return }
|
||||
for workspaceId in workspaceIds {
|
||||
guard !Task.isCancelled else { return }
|
||||
let reason = await primeBackgroundWorkspaceIfNeeded(workspaceId: workspaceId, tabManager: tabManager)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
switch reason {
|
||||
case .timeout:
|
||||
// Keep the hidden mount retained; pending background initial commands
|
||||
// must stay eligible to start until the surface is actually ready.
|
||||
continue
|
||||
case .cancelled:
|
||||
continue
|
||||
case .alreadyCleared, .surfaceReady, .workspaceRemoved:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func primeBackgroundWorkspaceIfNeeded(
|
||||
workspaceId: UUID,
|
||||
tabManager: TabManager
|
||||
) async -> PrimeCompletionReason {
|
||||
guard tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else {
|
||||
tabManager.releaseBackgroundWorkspaceMount(for: workspaceId)
|
||||
return .alreadyCleared
|
||||
}
|
||||
tabManager.retainBackgroundWorkspaceMount(for: workspaceId)
|
||||
|
||||
#if DEBUG
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
cmuxDebugLog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))")
|
||||
#endif
|
||||
|
||||
let completionReason: PrimeCompletionReason
|
||||
switch stepBackgroundWorkspacePrime(workspaceId: workspaceId, tabManager: tabManager) {
|
||||
case .completed(let reason):
|
||||
completionReason = reason
|
||||
case .pending:
|
||||
completionReason = await waitForBackgroundWorkspacePrimeCompletion(
|
||||
workspaceId: workspaceId,
|
||||
timeoutSeconds: Policy.timeoutSeconds,
|
||||
tabManager: tabManager
|
||||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000
|
||||
cmuxDebugLog(
|
||||
"workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"reason=\(completionReason.rawValue) ms=\(String(format: "%.2f", elapsedMs))"
|
||||
)
|
||||
#endif
|
||||
return completionReason
|
||||
}
|
||||
|
||||
private func stepBackgroundWorkspacePrime(workspaceId: UUID, tabManager: TabManager) -> PrimeState {
|
||||
guard tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else {
|
||||
tabManager.releaseBackgroundWorkspaceMount(for: workspaceId)
|
||||
return .completed(reason: .alreadyCleared)
|
||||
}
|
||||
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
|
||||
tabManager.completeBackgroundWorkspaceLoad(for: workspaceId)
|
||||
return .completed(reason: .workspaceRemoved)
|
||||
}
|
||||
|
||||
workspace.requestBackgroundPrimeTerminalSurfaceStartIfNeeded()
|
||||
guard workspace.hasLoadedBackgroundPrimeTerminalSurface() else {
|
||||
return .pending
|
||||
}
|
||||
|
||||
tabManager.completeBackgroundWorkspaceLoad(for: workspaceId)
|
||||
return .completed(reason: .surfaceReady)
|
||||
}
|
||||
|
||||
private func waitForBackgroundWorkspacePrimeCompletion(
|
||||
workspaceId: UUID,
|
||||
timeoutSeconds: TimeInterval,
|
||||
tabManager: TabManager
|
||||
) async -> PrimeCompletionReason {
|
||||
let waiter = Waiter()
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<PrimeCompletionReason, Never>) in
|
||||
waiter.start(continuation: continuation)
|
||||
guard !waiter.isResolved else { return }
|
||||
|
||||
installReadinessObservers(
|
||||
waiter: waiter,
|
||||
workspaceId: workspaceId,
|
||||
tabManager: tabManager
|
||||
)
|
||||
|
||||
let timeoutNanoseconds = UInt64(timeoutSeconds * 1_000_000_000)
|
||||
let timeoutTask = Task { @MainActor [weak self, weak waiter, weak tabManager] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: timeoutNanoseconds)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled, let self, let waiter, let tabManager else { return }
|
||||
if case .completed(let reason) = self.stepBackgroundWorkspacePrime(
|
||||
workspaceId: workspaceId,
|
||||
tabManager: tabManager
|
||||
) {
|
||||
waiter.finish(reason: reason)
|
||||
} else {
|
||||
waiter.finish(reason: .timeout)
|
||||
}
|
||||
}
|
||||
waiter.addTask(timeoutTask)
|
||||
|
||||
evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
|
||||
}
|
||||
} onCancel: {
|
||||
waiter.finish(reason: .cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
private func installReadinessObservers(
|
||||
waiter: Waiter,
|
||||
workspaceId: UUID,
|
||||
tabManager: TabManager
|
||||
) {
|
||||
let readyObserver = NotificationCenter.default.addObserver(
|
||||
forName: .terminalSurfaceDidBecomeReady,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self, weak waiter, weak tabManager] notification in
|
||||
guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID,
|
||||
readyWorkspaceId == workspaceId,
|
||||
let self,
|
||||
let waiter,
|
||||
let tabManager else { return }
|
||||
Task { @MainActor in
|
||||
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
|
||||
}
|
||||
}
|
||||
waiter.addObserver(readyObserver)
|
||||
|
||||
let hostedViewObserver = NotificationCenter.default.addObserver(
|
||||
forName: .terminalSurfaceHostedViewDidMoveToWindow,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self, weak waiter, weak tabManager] notification in
|
||||
guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID,
|
||||
readyWorkspaceId == workspaceId,
|
||||
let self,
|
||||
let waiter,
|
||||
let tabManager else { return }
|
||||
Task { @MainActor in
|
||||
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
|
||||
}
|
||||
}
|
||||
waiter.addObserver(hostedViewObserver)
|
||||
|
||||
let pendingObserver = tabManager.$pendingBackgroundWorkspaceLoadIds
|
||||
.dropFirst()
|
||||
.sink { [weak self, weak waiter, weak tabManager] pendingIds in
|
||||
guard !pendingIds.contains(workspaceId),
|
||||
let self,
|
||||
let waiter,
|
||||
let tabManager else { return }
|
||||
Task { @MainActor in
|
||||
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
|
||||
}
|
||||
}
|
||||
waiter.addCancellable(pendingObserver)
|
||||
|
||||
let tabsObserver = tabManager.$tabs
|
||||
.dropFirst()
|
||||
.sink { [weak self, weak waiter, weak tabManager] tabs in
|
||||
guard !tabs.contains(where: { $0.id == workspaceId }),
|
||||
let self,
|
||||
let waiter,
|
||||
let tabManager else { return }
|
||||
Task { @MainActor in
|
||||
self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager)
|
||||
}
|
||||
}
|
||||
waiter.addCancellable(tabsObserver)
|
||||
}
|
||||
|
||||
private func evaluate(waiter: Waiter, workspaceId: UUID, tabManager: TabManager) {
|
||||
switch stepBackgroundWorkspacePrime(workspaceId: workspaceId, tabManager: tabManager) {
|
||||
case .pending:
|
||||
break
|
||||
case .completed(let reason):
|
||||
waiter.finish(reason: reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BetaFeaturesSettingsView: View {
|
||||
@Binding var feedEnabled: Bool
|
||||
@Binding var dockEnabled: Bool
|
||||
|
||||
private var feedSubtitle: String {
|
||||
if feedEnabled {
|
||||
return String(
|
||||
localized: "settings.betaFeatures.feed.subtitleOn",
|
||||
defaultValue: "Shows Feed in the right sidebar mode switcher for inline agent decisions."
|
||||
)
|
||||
}
|
||||
return String(
|
||||
localized: "settings.betaFeatures.feed.subtitleOff",
|
||||
defaultValue: "Hides Feed from the right sidebar until you enable it here."
|
||||
)
|
||||
}
|
||||
|
||||
private var dockSubtitle: String {
|
||||
if dockEnabled {
|
||||
return String(
|
||||
@@ -37,29 +23,12 @@ struct BetaFeaturesSettingsView: View {
|
||||
BetaFeaturesWarningNote(
|
||||
String(
|
||||
localized: "settings.betaFeatures.warning",
|
||||
defaultValue: "These features are unstable and may change or break. Enable them only when you are testing them."
|
||||
defaultValue: "Dock is unstable and may change or break. Enable it only when you are testing it."
|
||||
)
|
||||
)
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
configurationReview: .settingsOnly,
|
||||
String(localized: "settings.betaFeatures.feed", defaultValue: "Feed"),
|
||||
subtitle: feedSubtitle,
|
||||
searchAnchorID: SettingsSearchIndex.settingID(for: .betaFeatures, idSuffix: "feed")
|
||||
) {
|
||||
Toggle("", isOn: $feedEnabled)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
.accessibilityIdentifier("SettingsBetaFeedToggle")
|
||||
.accessibilityLabel(
|
||||
String(localized: "settings.betaFeatures.feed", defaultValue: "Feed")
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
configurationReview: .settingsOnly,
|
||||
String(localized: "settings.betaFeatures.dock", defaultValue: "Dock"),
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import AppKit
|
||||
import Bonsplit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
final class BrowserPaneDropTargetView: NSView {
|
||||
weak var slotView: WindowBrowserSlotView?
|
||||
var dropContext: BrowserPaneDropContext?
|
||||
private var activeZone: DropZone?
|
||||
private weak var activeFileDropWebView: NSView?
|
||||
private weak var preparedFileDropWebView: NSView?
|
||||
private weak var performedFileDropWebView: NSView?
|
||||
#if DEBUG
|
||||
private var lastHitTestSignature: String?
|
||||
#endif
|
||||
|
||||
override var acceptsFirstResponder: Bool { false }
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
registerForDraggedTypes(Array(Set([
|
||||
DragOverlayRoutingPolicy.filePreviewTransferType,
|
||||
DragOverlayRoutingPolicy.bonsplitTabTransferType,
|
||||
]).union(PasteboardFileURLReader.fileURLPasteboardTypes)))
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
deinit {}
|
||||
|
||||
@MainActor
|
||||
static func shouldCaptureHitTesting(
|
||||
pasteboardTypes: [NSPasteboard.PasteboardType]?,
|
||||
eventType: NSEvent.EventType?
|
||||
) -> Bool {
|
||||
let hasFileURL = DragOverlayRoutingPolicy.hasFileURL(pasteboardTypes)
|
||||
let fileDropBehavior = DragOverlayRoutingPolicy.resolvedFileDropBehavior(
|
||||
pasteboardTypes: pasteboardTypes,
|
||||
modifierFlags: DragOverlayRoutingPolicy.currentModifierFlags,
|
||||
canDropAsText: true
|
||||
)
|
||||
let fileDropWantsPreview = fileDropBehavior == .preview
|
||||
let shouldCaptureFileDrop = fileDropBehavior != nil
|
||||
let hasFilePreviewTransfer = DragOverlayRoutingPolicy.hasFilePreviewTransfer(pasteboardTypes)
|
||||
let hasBonsplitTransfer = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
|
||||
let shouldCaptureFilePreviewTransfer = hasFilePreviewTransfer && (!hasFileURL || fileDropWantsPreview)
|
||||
let shouldCaptureBonsplitTransfer = hasBonsplitTransfer && !hasFilePreviewTransfer
|
||||
guard shouldCaptureBonsplitTransfer || shouldCaptureFilePreviewTransfer || shouldCaptureFileDrop else { return false }
|
||||
guard let eventType else { return false }
|
||||
|
||||
switch eventType {
|
||||
case .cursorUpdate,
|
||||
.mouseEntered,
|
||||
.mouseExited,
|
||||
.mouseMoved,
|
||||
.leftMouseDragged,
|
||||
.rightMouseDragged,
|
||||
.otherMouseDragged,
|
||||
.leftMouseUp,
|
||||
.rightMouseUp,
|
||||
.otherMouseUp,
|
||||
.appKitDefined,
|
||||
.applicationDefined,
|
||||
.systemDefined,
|
||||
.periodic:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
guard bounds.contains(point), dropContext != nil else { return nil }
|
||||
if shouldDeferToPaneTabBar(at: point) {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pasteboardTypes = NSPasteboard(name: .drag).types
|
||||
let eventType = NSApp.currentEvent?.type
|
||||
let capture = Self.shouldCaptureHitTesting(
|
||||
pasteboardTypes: pasteboardTypes,
|
||||
eventType: eventType
|
||||
)
|
||||
#if DEBUG
|
||||
logHitTestDecision(capture: capture, pasteboardTypes: pasteboardTypes, eventType: eventType)
|
||||
#endif
|
||||
return capture ? self : nil
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||
updateDragState(sender, phase: "entered")
|
||||
}
|
||||
|
||||
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||
updateDragState(sender, phase: "updated")
|
||||
}
|
||||
|
||||
override func draggingExited(_ sender: (any NSDraggingInfo)?) {
|
||||
exitActiveFileDropWebView(sender)
|
||||
clearDragState(phase: "exited")
|
||||
}
|
||||
|
||||
override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
||||
guard let dropContext else {
|
||||
#if DEBUG
|
||||
cmuxDebugLog("browser.paneDrop.prepare allowed=0 reason=missingContext")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
let location = convert(sender.draggingLocation, from: nil)
|
||||
if shouldRouteFileDropToHostedWebView(sender, at: location) {
|
||||
clearDragState(phase: "prepare.text")
|
||||
let webView = activeFileDropWebView ?? slotView?.hostedWebViewForFileDrop(at: location)
|
||||
let accepted = webView?.prepareForDragOperation(sender) ?? false
|
||||
preparedFileDropWebView = accepted ? webView : nil
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.prepareAsWebView panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"accepted=\(accepted ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return accepted
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
||||
defer {
|
||||
clearDragState(phase: "perform.clear")
|
||||
}
|
||||
|
||||
guard let dropContext else {
|
||||
#if DEBUG
|
||||
cmuxDebugLog("browser.paneDrop.perform allowed=0 reason=missingContext")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
let location = convert(sender.draggingLocation, from: nil)
|
||||
let zone = BrowserPaneDropRouting.zone(
|
||||
for: location,
|
||||
in: bounds.size,
|
||||
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
|
||||
)
|
||||
|
||||
if shouldRouteFileDropToHostedWebView(sender, at: location) {
|
||||
let webView = preparedFileDropWebView ?? activeFileDropWebView ?? slotView?.hostedWebViewForFileDrop(at: location)
|
||||
let handled = webView?.performDragOperation(sender) ?? false
|
||||
if handled {
|
||||
performedFileDropWebView = webView
|
||||
focusBrowserPanelAfterSuccessfulFileDrop(context: dropContext)
|
||||
} else {
|
||||
preparedFileDropWebView = nil
|
||||
performedFileDropWebView = nil
|
||||
}
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.performAsWebView panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"handled=\(handled ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return handled
|
||||
}
|
||||
|
||||
if let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard),
|
||||
transfer.isFromCurrentProcess {
|
||||
if transfer.isFilePreview {
|
||||
guard let entry = FilePreviewDragRegistry.shared.consume(id: transfer.tabId),
|
||||
let workspace = AppDelegate.shared?.workspaceFor(tabId: dropContext.workspaceId) else {
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"reason=missingFilePreviewEntry tab=\(transfer.tabId.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
let handled = workspace.handleFilePreviewDrop(
|
||||
entry: entry,
|
||||
destination: BrowserPaneDropRouting.filePreviewDestination(
|
||||
target: dropContext,
|
||||
zone: zone
|
||||
)
|
||||
)
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone) filePreview=1 handled=\(handled ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return handled
|
||||
}
|
||||
|
||||
guard let action = BrowserPaneDropRouting.action(
|
||||
for: transfer,
|
||||
target: dropContext,
|
||||
zone: zone
|
||||
) else {
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"reason=noAction zone=\(zone)"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
switch action {
|
||||
case .noOp:
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform allowed=1 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(transfer.tabId.uuidString.prefix(5)) action=noop"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
case .move(let tabId, let workspaceId, let targetPane, let splitTarget):
|
||||
let moved = AppDelegate.shared?.moveBonsplitTab(
|
||||
tabId: tabId,
|
||||
toWorkspace: workspaceId,
|
||||
targetPane: targetPane,
|
||||
splitTarget: splitTarget.map { ($0.orientation, $0.insertFirst) },
|
||||
focus: true,
|
||||
focusWindow: true
|
||||
) ?? false
|
||||
#if DEBUG
|
||||
let splitLabel = splitTarget.map {
|
||||
"\($0.orientation.rawValue):\($0.insertFirst ? 1 : 0)"
|
||||
} ?? "none"
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(tabId.uuidString.prefix(5)) zone=\(zone) pane=\(targetPane.id.uuidString.prefix(5)) " +
|
||||
"split=\(splitLabel) moved=\(moved ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return moved
|
||||
}
|
||||
}
|
||||
|
||||
let urls = DragOverlayRoutingPolicy.fileURLs(from: sender.draggingPasteboard)
|
||||
guard !urls.isEmpty,
|
||||
let workspace = AppDelegate.shared?.workspaceFor(tabId: dropContext.workspaceId) else {
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) reason=missingTransferAndFiles"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
let handled = workspace.handleExternalFileDrop(BonsplitController.ExternalFileDropRequest(
|
||||
urls: urls,
|
||||
destination: PaneDropRouting.filePreviewDestination(
|
||||
targetPane: dropContext.paneId,
|
||||
zone: zone
|
||||
)
|
||||
))
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"fileURLs=\(urls.count) zone=\(zone) handled=\(handled ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return handled
|
||||
}
|
||||
|
||||
override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) {
|
||||
defer {
|
||||
activeFileDropWebView = nil
|
||||
preparedFileDropWebView = nil
|
||||
performedFileDropWebView = nil
|
||||
clearDragState(phase: "conclude.clear")
|
||||
}
|
||||
guard let sender else { return }
|
||||
if let webView = performedFileDropWebView ?? preparedFileDropWebView ?? activeFileDropWebView {
|
||||
webView.concludeDragOperation(sender)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDragState(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation {
|
||||
let location = convert(sender.draggingLocation, from: nil)
|
||||
if shouldDeferToPaneTabBar(at: location) {
|
||||
exitActiveFileDropWebView(sender)
|
||||
clearDragState(phase: "\(phase).tabBar")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let dropContext else {
|
||||
exitActiveFileDropWebView(sender)
|
||||
clearDragState(phase: "\(phase).reject")
|
||||
return []
|
||||
}
|
||||
|
||||
let zone = BrowserPaneDropRouting.zone(
|
||||
for: location,
|
||||
in: bounds.size,
|
||||
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
|
||||
)
|
||||
|
||||
if shouldRouteFileDropToHostedWebView(sender, at: location) {
|
||||
clearDragState(phase: "\(phase).text")
|
||||
return updateHostedWebViewDragState(sender, at: location)
|
||||
}
|
||||
|
||||
exitActiveFileDropWebView(sender)
|
||||
|
||||
if let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard) {
|
||||
guard transfer.isFromCurrentProcess,
|
||||
(!transfer.isFilePreview || FilePreviewDragRegistry.shared.contains(id: transfer.tabId)) else {
|
||||
clearDragState(phase: "\(phase).reject")
|
||||
return []
|
||||
}
|
||||
activeZone = zone
|
||||
slotView?.setPortalDragDropZone(zone)
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone)"
|
||||
)
|
||||
#endif
|
||||
return .move
|
||||
}
|
||||
|
||||
guard DragOverlayRoutingPolicy.hasFileURL(sender.draggingPasteboard.types) else {
|
||||
clearDragState(phase: "\(phase).reject")
|
||||
return []
|
||||
}
|
||||
activeZone = zone
|
||||
slotView?.setPortalDragDropZone(zone)
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) fileURL=1 zone=\(zone)"
|
||||
)
|
||||
#endif
|
||||
return .copy
|
||||
}
|
||||
|
||||
private func shouldRouteFileDropToHostedWebView(_ sender: any NSDraggingInfo, at location: NSPoint) -> Bool {
|
||||
guard DragOverlayRoutingPolicy.hasFileURL(sender.draggingPasteboard.types) else { return false }
|
||||
let canDropIntoHostedWebView = slotView?.hostedWebViewForFileDrop(at: location) != nil
|
||||
return DragOverlayRoutingPolicy.shouldRouteFileDropToTextDestination(
|
||||
pasteboardTypes: sender.draggingPasteboard.types,
|
||||
modifierFlags: DragOverlayRoutingPolicy.currentModifierFlags,
|
||||
canDropAsText: canDropIntoHostedWebView
|
||||
)
|
||||
}
|
||||
|
||||
private func updateHostedWebViewDragState(_ sender: any NSDraggingInfo, at location: NSPoint) -> NSDragOperation {
|
||||
guard let webView = slotView?.hostedWebViewForFileDrop(at: location) else {
|
||||
exitActiveFileDropWebView(sender)
|
||||
return []
|
||||
}
|
||||
if activeFileDropWebView !== webView {
|
||||
exitActiveFileDropWebView(sender)
|
||||
activeFileDropWebView = webView
|
||||
return webView.draggingEntered(sender)
|
||||
}
|
||||
return webView.draggingUpdated(sender)
|
||||
}
|
||||
|
||||
private func exitActiveFileDropWebView(_ sender: (any NSDraggingInfo)?) {
|
||||
if let webView = activeFileDropWebView {
|
||||
webView.draggingExited(sender)
|
||||
activeFileDropWebView = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func focusBrowserPanelAfterSuccessfulFileDrop(context: BrowserPaneDropContext) {
|
||||
guard let workspace = AppDelegate.shared?.workspaceFor(tabId: context.workspaceId) else { return }
|
||||
FileDropTextDropController.focusPanelAfterSuccessfulTextDrop(
|
||||
workspace: workspace,
|
||||
panelId: context.panelId,
|
||||
focusIntent: .browser(.webView),
|
||||
window: window ?? slotView?.window
|
||||
)
|
||||
}
|
||||
|
||||
func shouldDeferToPaneTabBar(at point: NSPoint) -> Bool {
|
||||
let windowPoint = convert(point, to: nil)
|
||||
return BonsplitTabBarPassThrough
|
||||
.shouldPassThroughToPaneTabBar(windowPoint: windowPoint, below: self)
|
||||
.result
|
||||
}
|
||||
|
||||
private func clearDragState(phase: String) {
|
||||
guard activeZone != nil else { return }
|
||||
activeZone = nil
|
||||
slotView?.setPortalDragDropZone(nil)
|
||||
#if DEBUG
|
||||
if let dropContext {
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) zone=none"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func logHitTestDecision(
|
||||
capture: Bool,
|
||||
pasteboardTypes: [NSPasteboard.PasteboardType]?,
|
||||
eventType: NSEvent.EventType?
|
||||
) {
|
||||
let hasTransferType = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
|
||||
let hasFileURL = DragOverlayRoutingPolicy.hasFileURL(pasteboardTypes)
|
||||
guard hasTransferType || hasFileURL || capture else { return }
|
||||
|
||||
let signature = [
|
||||
capture ? "1" : "0",
|
||||
hasTransferType ? "1" : "0",
|
||||
hasFileURL ? "1" : "0",
|
||||
String(describing: dropContext != nil),
|
||||
eventType.map { String($0.rawValue) } ?? "nil",
|
||||
].joined(separator: "|")
|
||||
guard lastHitTestSignature != signature else { return }
|
||||
lastHitTestSignature = signature
|
||||
|
||||
let types = pasteboardTypes?.map(\.rawValue).joined(separator: ",") ?? "-"
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.hitTest capture=\(capture ? 1 : 0) " +
|
||||
"hasTransfer=\(hasTransferType ? 1 : 0) hasFileURL=\(hasFileURL ? 1 : 0) context=\(dropContext != nil ? 1 : 0) " +
|
||||
"event=\(eventType.map { String($0.rawValue) } ?? "nil") types=\(types)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1368,70 +1368,12 @@ enum BrowserPaneDropAction: Equatable {
|
||||
}
|
||||
|
||||
enum BrowserPaneDropRouting {
|
||||
private static let padding: CGFloat = 4
|
||||
|
||||
private static func fullPaneSize(for slotSize: CGSize, topChromeHeight: CGFloat) -> CGSize {
|
||||
CGSize(width: slotSize.width, height: slotSize.height + max(0, topChromeHeight))
|
||||
}
|
||||
|
||||
static func zone(for location: CGPoint, in size: CGSize, topChromeHeight: CGFloat = 0) -> DropZone {
|
||||
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
|
||||
let edgeRatio: CGFloat = 0.25
|
||||
let horizontalEdge = max(80, fullPaneSize.width * edgeRatio)
|
||||
let verticalEdge = max(80, fullPaneSize.height * edgeRatio)
|
||||
|
||||
if location.x < horizontalEdge {
|
||||
return .left
|
||||
} else if location.x > fullPaneSize.width - horizontalEdge {
|
||||
return .right
|
||||
} else if location.y > fullPaneSize.height - verticalEdge {
|
||||
return .top
|
||||
} else if location.y < verticalEdge {
|
||||
return .bottom
|
||||
} else {
|
||||
return .center
|
||||
}
|
||||
PaneDropRouting.zone(for: location, in: size, topChromeHeight: topChromeHeight)
|
||||
}
|
||||
|
||||
static func overlayFrame(for zone: DropZone, in size: CGSize, topChromeHeight: CGFloat = 0) -> CGRect {
|
||||
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
|
||||
switch zone {
|
||||
case .center:
|
||||
return CGRect(
|
||||
x: padding,
|
||||
y: padding,
|
||||
width: fullPaneSize.width - padding * 2,
|
||||
height: fullPaneSize.height - padding * 2
|
||||
)
|
||||
case .left:
|
||||
return CGRect(
|
||||
x: padding,
|
||||
y: padding,
|
||||
width: fullPaneSize.width / 2 - padding,
|
||||
height: fullPaneSize.height - padding * 2
|
||||
)
|
||||
case .right:
|
||||
return CGRect(
|
||||
x: fullPaneSize.width / 2,
|
||||
y: padding,
|
||||
width: fullPaneSize.width / 2 - padding,
|
||||
height: fullPaneSize.height - padding * 2
|
||||
)
|
||||
case .top:
|
||||
return CGRect(
|
||||
x: padding,
|
||||
y: fullPaneSize.height / 2,
|
||||
width: fullPaneSize.width - padding * 2,
|
||||
height: fullPaneSize.height / 2 - padding
|
||||
)
|
||||
case .bottom:
|
||||
return CGRect(
|
||||
x: padding,
|
||||
y: padding,
|
||||
width: fullPaneSize.width - padding * 2,
|
||||
height: fullPaneSize.height / 2 - padding
|
||||
)
|
||||
}
|
||||
PaneDropRouting.compactOverlayFrame(for: zone, in: size, topChromeHeight: topChromeHeight)
|
||||
}
|
||||
|
||||
static func action(
|
||||
@@ -1469,272 +1411,10 @@ enum BrowserPaneDropRouting {
|
||||
target: BrowserPaneDropContext,
|
||||
zone: DropZone
|
||||
) -> BonsplitController.ExternalTabDropRequest.Destination {
|
||||
switch zone {
|
||||
case .center:
|
||||
return .insert(targetPane: target.paneId, targetIndex: nil)
|
||||
case .left:
|
||||
return .split(targetPane: target.paneId, orientation: .horizontal, insertFirst: true)
|
||||
case .right:
|
||||
return .split(targetPane: target.paneId, orientation: .horizontal, insertFirst: false)
|
||||
case .top:
|
||||
return .split(targetPane: target.paneId, orientation: .vertical, insertFirst: true)
|
||||
case .bottom:
|
||||
return .split(targetPane: target.paneId, orientation: .vertical, insertFirst: false)
|
||||
}
|
||||
PaneDropRouting.filePreviewDestination(targetPane: target.paneId, zone: zone)
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserPaneDropTargetView: NSView {
|
||||
weak var slotView: WindowBrowserSlotView?
|
||||
var dropContext: BrowserPaneDropContext?
|
||||
private var activeZone: DropZone?
|
||||
#if DEBUG
|
||||
private var lastHitTestSignature: String?
|
||||
#endif
|
||||
|
||||
override var acceptsFirstResponder: Bool { false }
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
registerForDraggedTypes([
|
||||
DragOverlayRoutingPolicy.filePreviewTransferType,
|
||||
DragOverlayRoutingPolicy.bonsplitTabTransferType
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
static func shouldCaptureHitTesting(
|
||||
pasteboardTypes: [NSPasteboard.PasteboardType]?,
|
||||
eventType: NSEvent.EventType?
|
||||
) -> Bool {
|
||||
guard DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) else { return false }
|
||||
guard let eventType else { return false }
|
||||
|
||||
switch eventType {
|
||||
case .cursorUpdate,
|
||||
.mouseEntered,
|
||||
.mouseExited,
|
||||
.mouseMoved,
|
||||
.leftMouseDragged,
|
||||
.rightMouseDragged,
|
||||
.otherMouseDragged,
|
||||
.appKitDefined,
|
||||
.applicationDefined,
|
||||
.systemDefined,
|
||||
.periodic:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
guard bounds.contains(point), dropContext != nil else { return nil }
|
||||
if shouldDeferToPaneTabBar(at: point) {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pasteboardTypes = NSPasteboard(name: .drag).types
|
||||
let eventType = NSApp.currentEvent?.type
|
||||
let capture = Self.shouldCaptureHitTesting(
|
||||
pasteboardTypes: pasteboardTypes,
|
||||
eventType: eventType
|
||||
)
|
||||
#if DEBUG
|
||||
logHitTestDecision(capture: capture, pasteboardTypes: pasteboardTypes, eventType: eventType)
|
||||
#endif
|
||||
return capture ? self : nil
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||
updateDragState(sender, phase: "entered")
|
||||
}
|
||||
|
||||
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||
updateDragState(sender, phase: "updated")
|
||||
}
|
||||
|
||||
override func draggingExited(_ sender: (any NSDraggingInfo)?) {
|
||||
clearDragState(phase: "exited")
|
||||
}
|
||||
|
||||
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
||||
defer {
|
||||
clearDragState(phase: "perform.clear")
|
||||
}
|
||||
|
||||
guard let dropContext,
|
||||
let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard),
|
||||
transfer.isFromCurrentProcess else {
|
||||
#if DEBUG
|
||||
cmuxDebugLog("browser.paneDrop.perform allowed=0 reason=missingTransfer")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
let location = convert(sender.draggingLocation, from: nil)
|
||||
let zone = BrowserPaneDropRouting.zone(
|
||||
for: location,
|
||||
in: bounds.size,
|
||||
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
|
||||
)
|
||||
|
||||
if transfer.isFilePreview {
|
||||
guard let entry = FilePreviewDragRegistry.shared.consume(id: transfer.tabId),
|
||||
let workspace = AppDelegate.shared?.workspaceFor(tabId: dropContext.workspaceId) else {
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"reason=missingFilePreviewEntry tab=\(transfer.tabId.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
let handled = workspace.handleFilePreviewDrop(
|
||||
entry: entry,
|
||||
destination: BrowserPaneDropRouting.filePreviewDestination(
|
||||
target: dropContext,
|
||||
zone: zone
|
||||
)
|
||||
)
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone) filePreview=1 handled=\(handled ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return handled
|
||||
}
|
||||
|
||||
guard let action = BrowserPaneDropRouting.action(
|
||||
for: transfer,
|
||||
target: dropContext,
|
||||
zone: zone
|
||||
) else {
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"reason=noAction zone=\(zone)"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
switch action {
|
||||
case .noOp:
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform allowed=1 panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(transfer.tabId.uuidString.prefix(5)) action=noop"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
case .move(let tabId, let workspaceId, let targetPane, let splitTarget):
|
||||
let moved = AppDelegate.shared?.moveBonsplitTab(
|
||||
tabId: tabId,
|
||||
toWorkspace: workspaceId,
|
||||
targetPane: targetPane,
|
||||
splitTarget: splitTarget.map { ($0.orientation, $0.insertFirst) },
|
||||
focus: true,
|
||||
focusWindow: true
|
||||
) ?? false
|
||||
#if DEBUG
|
||||
let splitLabel = splitTarget.map {
|
||||
"\($0.orientation.rawValue):\($0.insertFirst ? 1 : 0)"
|
||||
} ?? "none"
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(tabId.uuidString.prefix(5)) zone=\(zone) pane=\(targetPane.id.uuidString.prefix(5)) " +
|
||||
"split=\(splitLabel) moved=\(moved ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return moved
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDragState(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation {
|
||||
let location = convert(sender.draggingLocation, from: nil)
|
||||
if shouldDeferToPaneTabBar(at: location) {
|
||||
clearDragState(phase: "\(phase).tabBar")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let dropContext,
|
||||
let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard),
|
||||
transfer.isFromCurrentProcess,
|
||||
(!transfer.isFilePreview || FilePreviewDragRegistry.shared.contains(id: transfer.tabId)) else {
|
||||
clearDragState(phase: "\(phase).reject")
|
||||
return []
|
||||
}
|
||||
|
||||
let zone = BrowserPaneDropRouting.zone(
|
||||
for: location,
|
||||
in: bounds.size,
|
||||
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
|
||||
)
|
||||
activeZone = zone
|
||||
slotView?.setPortalDragDropZone(zone)
|
||||
#if DEBUG
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone)"
|
||||
)
|
||||
#endif
|
||||
return .move
|
||||
}
|
||||
|
||||
func shouldDeferToPaneTabBar(at point: NSPoint) -> Bool {
|
||||
let windowPoint = convert(point, to: nil)
|
||||
return BonsplitTabBarPassThrough
|
||||
.shouldPassThroughToPaneTabBar(windowPoint: windowPoint, below: self)
|
||||
.result
|
||||
}
|
||||
|
||||
private func clearDragState(phase: String) {
|
||||
guard activeZone != nil else { return }
|
||||
activeZone = nil
|
||||
slotView?.setPortalDragDropZone(nil)
|
||||
#if DEBUG
|
||||
if let dropContext {
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) zone=none"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func logHitTestDecision(
|
||||
capture: Bool,
|
||||
pasteboardTypes: [NSPasteboard.PasteboardType]?,
|
||||
eventType: NSEvent.EventType?
|
||||
) {
|
||||
let hasTransferType = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
|
||||
guard hasTransferType || capture else { return }
|
||||
|
||||
let signature = [
|
||||
capture ? "1" : "0",
|
||||
hasTransferType ? "1" : "0",
|
||||
String(describing: dropContext != nil),
|
||||
eventType.map { String($0.rawValue) } ?? "nil",
|
||||
].joined(separator: "|")
|
||||
guard lastHitTestSignature != signature else { return }
|
||||
lastHitTestSignature = signature
|
||||
|
||||
let types = pasteboardTypes?.map(\.rawValue).joined(separator: ",") ?? "-"
|
||||
cmuxDebugLog(
|
||||
"browser.paneDrop.hitTest capture=\(capture ? 1 : 0) " +
|
||||
"hasTransfer=\(hasTransferType ? 1 : 0) context=\(dropContext != nil ? 1 : 0) " +
|
||||
"event=\(eventType.map { String($0.rawValue) } ?? "nil") types=\(types)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
final class WindowBrowserSlotView: NSView {
|
||||
override var isOpaque: Bool { false }
|
||||
override var isHidden: Bool {
|
||||
@@ -1845,6 +1525,22 @@ final class WindowBrowserSlotView: NSView {
|
||||
paneDropTargetView.dropContext = context
|
||||
}
|
||||
|
||||
func paneDropTargetForDrop(at localPoint: NSPoint) -> BrowserPaneDropTargetView? {
|
||||
guard paneDropTargetView.dropContext != nil else { return nil }
|
||||
guard bounds.contains(localPoint) else { return nil }
|
||||
let pointInTarget = paneDropTargetView.convert(localPoint, from: self)
|
||||
guard paneDropTargetView.bounds.contains(pointInTarget) else { return nil }
|
||||
guard !paneDropTargetView.shouldDeferToPaneTabBar(at: pointInTarget) else { return nil }
|
||||
return paneDropTargetView
|
||||
}
|
||||
|
||||
func hostedWebViewForFileDrop(at localPoint: NSPoint) -> WKWebView? {
|
||||
guard let hostedWebView else { return nil }
|
||||
let webPoint = hostedWebView.convert(localPoint, from: self)
|
||||
guard hostedWebView.bounds.contains(webPoint) else { return nil }
|
||||
return hostedWebView
|
||||
}
|
||||
|
||||
func setPaneTopChromeHeight(_ height: CGFloat) {
|
||||
let resolvedHeight = max(0, height)
|
||||
guard abs(paneTopChromeHeight - resolvedHeight) > 0.5 else { return }
|
||||
@@ -4069,6 +3765,19 @@ final class WindowBrowserPortal: NSObject {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func browserPaneDropTargetAtWindowPoint(_ windowPoint: NSPoint) -> BrowserPaneDropTargetView? {
|
||||
guard ensureInstalled() else { return nil }
|
||||
let point = hostView.convert(windowPoint, from: nil)
|
||||
for subview in hostView.subviews.reversed() {
|
||||
guard let container = subview as? WindowBrowserSlotView else { continue }
|
||||
guard !container.isHidden else { continue }
|
||||
guard container.frame.contains(point) else { continue }
|
||||
let pointInContainer = container.convert(point, from: hostView)
|
||||
return container.paneDropTargetForDrop(at: pointInContainer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -4282,6 +3991,15 @@ enum BrowserWindowPortalRegistry {
|
||||
return portal.webViewAtWindowPoint(windowPoint)
|
||||
}
|
||||
|
||||
static func browserPaneDropTargetAtWindowPoint(
|
||||
_ windowPoint: NSPoint,
|
||||
in window: NSWindow
|
||||
) -> BrowserPaneDropTargetView? {
|
||||
let windowId = ObjectIdentifier(window)
|
||||
guard let portal = portalsByWindowId[windowId] else { return nil }
|
||||
return portal.browserPaneDropTargetAtWindowPoint(windowPoint)
|
||||
}
|
||||
|
||||
static func refresh(webView: WKWebView, reason: String) {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId[webViewId],
|
||||
|
||||
@@ -113,7 +113,7 @@ final class CloudVMActionLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
private final class ProcessOutputCollector: @unchecked Sendable {
|
||||
final class ProcessOutputCollector: @unchecked Sendable {
|
||||
private enum Stream {
|
||||
case stdout
|
||||
case stderr
|
||||
|
||||
@@ -13,13 +13,10 @@ struct CmuxConfigFile: Codable, Sendable {
|
||||
var newWorkspaceCommand: String?
|
||||
var surfaceTabBarButtons: [CmuxSurfaceTabBarButton]?
|
||||
var commands: [CmuxCommandDefinition]
|
||||
var vault: CmuxVaultConfigDefinition?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case actions
|
||||
case ui
|
||||
case newWorkspaceCommand
|
||||
case surfaceTabBarButtons
|
||||
case commands
|
||||
case actions, ui, newWorkspaceCommand, surfaceTabBarButtons, commands, vault
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -27,13 +24,15 @@ struct CmuxConfigFile: Codable, Sendable {
|
||||
ui: CmuxConfigUIDefinition? = nil,
|
||||
newWorkspaceCommand: String? = nil,
|
||||
surfaceTabBarButtons: [CmuxSurfaceTabBarButton]? = nil,
|
||||
commands: [CmuxCommandDefinition] = []
|
||||
commands: [CmuxCommandDefinition] = [],
|
||||
vault: CmuxVaultConfigDefinition? = nil
|
||||
) {
|
||||
self.actions = actions
|
||||
self.ui = ui
|
||||
self.newWorkspaceCommand = newWorkspaceCommand
|
||||
self.surfaceTabBarButtons = surfaceTabBarButtons
|
||||
self.commands = commands
|
||||
self.vault = vault
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -79,6 +78,7 @@ struct CmuxConfigFile: Codable, Sendable {
|
||||
surfaceTabBarButtons = nil
|
||||
}
|
||||
commands = try container.decodeIfPresent([CmuxCommandDefinition].self, forKey: .commands) ?? []
|
||||
vault = try container.decodeIfPresent(CmuxVaultConfigDefinition.self, forKey: .vault)
|
||||
}
|
||||
|
||||
private static func normalizedActions(
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import Foundation
|
||||
|
||||
struct CmuxEventSubscriptionSnapshot {
|
||||
let subscription: CmuxEventSubscription
|
||||
let replay: [[String: Any]]
|
||||
let ack: [String: Any]
|
||||
}
|
||||
|
||||
// Sendable safety: every mutable field is protected by `lock`; `semaphore` only wakes `next(timeout:)`.
|
||||
final class CmuxEventSubscription: @unchecked Sendable {
|
||||
let id: UUID
|
||||
let names: Set<String>
|
||||
let categories: Set<String>
|
||||
let maxPendingEvents: Int
|
||||
|
||||
private let lock = NSLock()
|
||||
private let semaphore = DispatchSemaphore(value: 0)
|
||||
private var queue: [[String: Any]] = []
|
||||
private var closed = false
|
||||
private var closedReason: String?
|
||||
|
||||
init(id: UUID = UUID(), names: Set<String>, categories: Set<String>, maxPendingEvents: Int) {
|
||||
self.id = id
|
||||
self.names = names
|
||||
self.categories = categories
|
||||
self.maxPendingEvents = max(1, maxPendingEvents)
|
||||
}
|
||||
|
||||
func accepts(_ event: [String: Any]) -> Bool {
|
||||
if !names.isEmpty {
|
||||
guard let name = event["name"] as? String, names.contains(name) else { return false }
|
||||
}
|
||||
if !categories.isEmpty {
|
||||
guard let category = event["category"] as? String, categories.contains(category) else { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var isClosed: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return closed
|
||||
}
|
||||
|
||||
var closeReason: String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return closedReason
|
||||
}
|
||||
|
||||
func enqueue(_ event: [String: Any]) -> Bool {
|
||||
lock.lock()
|
||||
let shouldSignal: Bool
|
||||
let accepted: Bool
|
||||
if closed {
|
||||
shouldSignal = false
|
||||
accepted = false
|
||||
} else if queue.count >= maxPendingEvents {
|
||||
closed = true
|
||||
closedReason = "pending event buffer exceeded \(maxPendingEvents) events"
|
||||
queue.removeAll()
|
||||
shouldSignal = true
|
||||
accepted = false
|
||||
} else {
|
||||
queue.append(event)
|
||||
shouldSignal = true
|
||||
accepted = true
|
||||
}
|
||||
lock.unlock()
|
||||
if shouldSignal {
|
||||
semaphore.signal()
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
func next(timeout: TimeInterval) -> [String: Any]? {
|
||||
lock.lock()
|
||||
if !queue.isEmpty {
|
||||
let event = queue.removeFirst()
|
||||
lock.unlock()
|
||||
return event
|
||||
}
|
||||
if closed {
|
||||
lock.unlock()
|
||||
return nil
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
let result = semaphore.wait(timeout: .now() + timeout)
|
||||
guard result == .success else { return nil }
|
||||
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !queue.isEmpty else { return nil }
|
||||
return queue.removeFirst()
|
||||
}
|
||||
|
||||
func close(reason: String? = nil) {
|
||||
lock.lock()
|
||||
closed = true
|
||||
if let reason {
|
||||
closedReason = reason
|
||||
}
|
||||
queue.removeAll()
|
||||
lock.unlock()
|
||||
semaphore.signal()
|
||||
}
|
||||
}
|
||||
|
||||
// Sendable safety: event state is protected by `lock`; disk appends are delegated to `CmuxEventLogWriter`.
|
||||
final class CmuxEventBus: @unchecked Sendable {
|
||||
static let shared = CmuxEventBus(eventLogURL: defaultEventLogURL())
|
||||
static let protocolName = "cmux-events"
|
||||
static let protocolVersion = 1
|
||||
static let defaultHeartbeatIntervalSeconds: TimeInterval = 15
|
||||
static let defaultRetainedEventLimit = 4_096
|
||||
static let defaultMaxEventLineBytes = 16 * 1024
|
||||
static let defaultMaxEventLogBytes: UInt64 = 16 * 1024 * 1024
|
||||
static let defaultMaxPendingEventLogLines = CmuxEventLogWriter.defaultMaxPendingLines
|
||||
static let defaultMaxPendingEventsPerSubscription = 1_024
|
||||
static let maxSanitizedStringBytes = 8 * 1024
|
||||
static let maxSanitizedArrayItems = 256
|
||||
static let maxSanitizedObjectEntries = 256
|
||||
static let maxSanitizedDepth = 12
|
||||
private static let isoFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter(); formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]; return formatter }()
|
||||
private static let isoFormatterLock = NSLock()
|
||||
|
||||
private let lock = NSLock()
|
||||
private let retainedEventLimit: Int
|
||||
private let maxEventLineBytes: Int
|
||||
private let maxPendingEventsPerSubscription: Int
|
||||
private let eventLogWriter: CmuxEventLogWriter?
|
||||
private let bootId = UUID().uuidString
|
||||
private var nextSequence: Int64 = 1
|
||||
private var retained: [[String: Any]] = []
|
||||
private var subscriptions: [UUID: CmuxEventSubscription] = [:]
|
||||
|
||||
init(
|
||||
retainedEventLimit: Int = CmuxEventBus.defaultRetainedEventLimit,
|
||||
eventLogURL: URL? = nil,
|
||||
maxEventLogBytes: UInt64 = CmuxEventBus.defaultMaxEventLogBytes,
|
||||
maxEventLineBytes: Int = CmuxEventBus.defaultMaxEventLineBytes,
|
||||
maxPendingEventLogLines: Int = CmuxEventBus.defaultMaxPendingEventLogLines,
|
||||
maxPendingEventsPerSubscription: Int = CmuxEventBus.defaultMaxPendingEventsPerSubscription
|
||||
) {
|
||||
self.retainedEventLimit = max(1, retainedEventLimit)
|
||||
self.maxEventLineBytes = max(1, maxEventLineBytes)
|
||||
self.maxPendingEventsPerSubscription = max(1, maxPendingEventsPerSubscription)
|
||||
self.eventLogWriter = eventLogURL.map {
|
||||
CmuxEventLogWriter(
|
||||
eventLogURL: $0,
|
||||
maxEventLogBytes: maxEventLogBytes,
|
||||
maxPendingLines: maxPendingEventLogLines
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var latestSequence: Int64 {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return nextSequence - 1
|
||||
}
|
||||
|
||||
func publish(
|
||||
name: String,
|
||||
category: String,
|
||||
source: String,
|
||||
workspaceId: String? = nil,
|
||||
surfaceId: String? = nil,
|
||||
paneId: String? = nil,
|
||||
windowId: String? = nil,
|
||||
payload: [String: Any] = [:]
|
||||
) {
|
||||
let occurredAt = Self.isoTimestamp(Date())
|
||||
let cleanPayload = Self.sanitizedJSONValue(payload)
|
||||
|
||||
lock.lock()
|
||||
let sequence = nextSequence
|
||||
nextSequence += 1
|
||||
|
||||
var event: [String: Any] = [
|
||||
"type": "event",
|
||||
"protocol": Self.protocolName,
|
||||
"version": Self.protocolVersion,
|
||||
"boot_id": bootId,
|
||||
"seq": sequence,
|
||||
"id": "\(bootId)-\(sequence)",
|
||||
"name": name,
|
||||
"category": category,
|
||||
"source": source,
|
||||
"occurred_at": occurredAt,
|
||||
"workspace_id": workspaceId ?? NSNull(),
|
||||
"surface_id": surfaceId ?? NSNull(),
|
||||
"pane_id": paneId ?? NSNull(),
|
||||
"window_id": windowId ?? NSNull(),
|
||||
"payload": cleanPayload
|
||||
]
|
||||
|
||||
event = Self.eventByApplyingEncodedByteLimit(event, maxBytes: maxEventLineBytes)
|
||||
retained.append(event)
|
||||
if retained.count > retainedEventLimit {
|
||||
retained.removeFirst(retained.count - retainedEventLimit)
|
||||
}
|
||||
let encodedLine = Self.encodeLine(event)
|
||||
let liveSubscriptions = Array(subscriptions.values)
|
||||
lock.unlock()
|
||||
|
||||
if let encodedLine { eventLogWriter?.enqueue(encodedLine) }
|
||||
|
||||
for subscription in liveSubscriptions where subscription.accepts(event) {
|
||||
if !subscription.enqueue(event) {
|
||||
removeSubscriptionIfStillActive(subscription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func subscribe(
|
||||
afterSequence: Int64?,
|
||||
names: Set<String>,
|
||||
categories: Set<String>
|
||||
) -> CmuxEventSubscriptionSnapshot {
|
||||
let subscription = CmuxEventSubscription(
|
||||
names: names,
|
||||
categories: categories,
|
||||
maxPendingEvents: maxPendingEventsPerSubscription
|
||||
)
|
||||
|
||||
lock.lock()
|
||||
let oldestSequence = Self.int64(retained.first?["seq"]) ?? nextSequence
|
||||
let latestSequence = nextSequence - 1
|
||||
let replay = retained.filter { event in
|
||||
let seq = Self.int64(event["seq"]) ?? 0
|
||||
let after = afterSequence ?? latestSequence
|
||||
return seq > after && subscription.accepts(event)
|
||||
}
|
||||
let requestedAfter = afterSequence ?? latestSequence
|
||||
let gapReason: String? = afterSequence.flatMap { after in
|
||||
if !retained.isEmpty, after < oldestSequence - 1 {
|
||||
return "requested sequence is older than the retained in-memory event log"
|
||||
}
|
||||
if after > latestSequence {
|
||||
return "requested sequence is newer than this cmux process; cmux probably restarted"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
let gap = gapReason != nil
|
||||
subscriptions[subscription.id] = subscription
|
||||
lock.unlock()
|
||||
|
||||
var resume: [String: Any] = [
|
||||
"after_seq": afterSequence.map { NSNumber(value: $0) } ?? NSNull(),
|
||||
"requested_after_seq": NSNumber(value: requestedAfter),
|
||||
"oldest_seq": NSNumber(value: oldestSequence),
|
||||
"latest_seq": NSNumber(value: latestSequence),
|
||||
"next_seq": NSNumber(value: latestSequence + 1),
|
||||
"gap": gap
|
||||
]
|
||||
if let gapReason {
|
||||
resume["gap_reason"] = gapReason
|
||||
}
|
||||
|
||||
let ack: [String: Any] = [
|
||||
"type": "ack",
|
||||
"protocol": Self.protocolName,
|
||||
"version": Self.protocolVersion,
|
||||
"boot_id": bootId,
|
||||
"subscription_id": subscription.id.uuidString,
|
||||
"heartbeat_interval_seconds": NSNumber(value: Self.defaultHeartbeatIntervalSeconds),
|
||||
"replay_count": replay.count,
|
||||
"resume": resume,
|
||||
"filters": [
|
||||
"names": Array(names).sorted(),
|
||||
"categories": Array(categories).sorted()
|
||||
]
|
||||
]
|
||||
|
||||
return CmuxEventSubscriptionSnapshot(subscription: subscription, replay: replay, ack: ack)
|
||||
}
|
||||
|
||||
func unsubscribe(_ subscription: CmuxEventSubscription) {
|
||||
lock.lock()
|
||||
subscriptions.removeValue(forKey: subscription.id)
|
||||
lock.unlock()
|
||||
subscription.close()
|
||||
}
|
||||
|
||||
private func removeSubscriptionIfStillActive(_ subscription: CmuxEventSubscription) {
|
||||
lock.lock()
|
||||
if subscriptions[subscription.id] === subscription {
|
||||
subscriptions.removeValue(forKey: subscription.id)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func heartbeat(subscription: CmuxEventSubscription) -> [String: Any] {
|
||||
[
|
||||
"type": "heartbeat",
|
||||
"protocol": Self.protocolName,
|
||||
"version": Self.protocolVersion,
|
||||
"boot_id": bootId,
|
||||
"subscription_id": subscription.id.uuidString,
|
||||
"latest_seq": NSNumber(value: latestSequence),
|
||||
"occurred_at": Self.isoTimestamp(Date())
|
||||
]
|
||||
}
|
||||
|
||||
func retainedSnapshot() -> [[String: Any]] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return retained
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func resetForTesting() {
|
||||
lock.lock()
|
||||
nextSequence = 1
|
||||
retained.removeAll()
|
||||
let active = Array(subscriptions.values)
|
||||
subscriptions.removeAll()
|
||||
lock.unlock()
|
||||
active.forEach { $0.close() }
|
||||
eventLogWriter?.resetForTesting()
|
||||
}
|
||||
|
||||
func flushEventLogForTesting() {
|
||||
eventLogWriter?.flushForTesting()
|
||||
}
|
||||
|
||||
func setEventLogFlushSuspendedForTesting(_ suspended: Bool) {
|
||||
eventLogWriter?.setFlushSuspendedForTesting(suspended)
|
||||
}
|
||||
|
||||
func eventLogBacklogSnapshotForTesting() -> (pending: Int, dropped: Int) {
|
||||
eventLogWriter?.backlogSnapshotForTesting() ?? (0, 0)
|
||||
}
|
||||
#endif
|
||||
|
||||
static func defaultEventLogURL() -> URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".cmuxterm", isDirectory: true)
|
||||
.appendingPathComponent("events.jsonl")
|
||||
}
|
||||
|
||||
static func encodeLine(_ object: [String: Any]) -> String? {
|
||||
let clean = sanitizedJSONValue(object)
|
||||
guard JSONSerialization.isValidJSONObject(clean),
|
||||
let data = try? JSONSerialization.data(withJSONObject: clean, options: [.sortedKeys]),
|
||||
let string = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return string.replacingOccurrences(of: "\n", with: "\\n")
|
||||
}
|
||||
|
||||
static func int64(_ value: Any?) -> Int64? {
|
||||
if let string = value as? String { return Int64(string) }
|
||||
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
|
||||
let type = String(cString: number.objCType)
|
||||
guard ["c", "C", "s", "S", "i", "I", "l", "L", "q", "Q"].contains(type) else { return nil }
|
||||
let int64 = number.int64Value
|
||||
return number.compare(NSNumber(value: int64)) == .orderedSame ? int64 : nil
|
||||
}
|
||||
|
||||
static func sanitizedJSONValue(_ value: Any) -> Any {
|
||||
sanitizedJSONValue(value, depth: 0)
|
||||
}
|
||||
|
||||
private static func sanitizedJSONValue(_ value: Any, depth: Int) -> Any {
|
||||
guard depth <= maxSanitizedDepth else {
|
||||
return "[truncated: max depth]"
|
||||
}
|
||||
|
||||
let mirror = Mirror(reflecting: value)
|
||||
if mirror.displayStyle == .optional {
|
||||
guard let child = mirror.children.first else { return NSNull() }
|
||||
return sanitizedJSONValue(child.value, depth: depth + 1)
|
||||
}
|
||||
|
||||
switch value {
|
||||
case let value as NSNull:
|
||||
return value
|
||||
case let value as UUID:
|
||||
return value.uuidString
|
||||
case let value as Date:
|
||||
return isoTimestamp(value)
|
||||
case let value as String:
|
||||
return truncatedString(value, maxUTF8Bytes: maxSanitizedStringBytes)
|
||||
case let value as NSNumber:
|
||||
if CFGetTypeID(value) == CFBooleanGetTypeID() {
|
||||
return value.boolValue
|
||||
}
|
||||
return value
|
||||
case let value as Bool:
|
||||
return value
|
||||
case let value as Int:
|
||||
return value
|
||||
case let value as Int64:
|
||||
return NSNumber(value: value)
|
||||
case let value as UInt64:
|
||||
return NSNumber(value: min(value, UInt64(Int64.max)))
|
||||
case let value as Double:
|
||||
return value.isFinite ? value : NSNull()
|
||||
case let value as Float:
|
||||
return value.isFinite ? Double(value) : NSNull()
|
||||
case let value as [String: Any]:
|
||||
var result: [String: Any] = [:]
|
||||
for key in value.keys.sorted().prefix(maxSanitizedObjectEntries) {
|
||||
result[truncatedString(key, maxUTF8Bytes: 256)] = sanitizedJSONValue(value[key] as Any, depth: depth + 1)
|
||||
}
|
||||
if value.count > maxSanitizedObjectEntries {
|
||||
result["__cmux_truncated_entries"] = value.count - maxSanitizedObjectEntries
|
||||
}
|
||||
return result
|
||||
case let value as [Any]:
|
||||
var result = value.prefix(maxSanitizedArrayItems).map { sanitizedJSONValue($0, depth: depth + 1) }
|
||||
if value.count > maxSanitizedArrayItems {
|
||||
result.append(["__cmux_truncated_items": value.count - maxSanitizedArrayItems])
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return truncatedString(String(describing: value), maxUTF8Bytes: maxSanitizedStringBytes)
|
||||
}
|
||||
}
|
||||
|
||||
private static func eventByApplyingEncodedByteLimit(_ event: [String: Any], maxBytes: Int) -> [String: Any] {
|
||||
guard maxBytes > 0,
|
||||
let line = encodeLine(event),
|
||||
line.utf8.count > maxBytes else {
|
||||
return event
|
||||
}
|
||||
|
||||
var compact = event
|
||||
let payload = event["payload"] as? [String: Any] ?? [:]
|
||||
compact["payload_truncated"] = true
|
||||
compact["payload"] = [
|
||||
"truncated": true,
|
||||
"reason": "event exceeded max encoded byte limit",
|
||||
"max_bytes": maxBytes,
|
||||
"original_payload_keys": Array(payload.keys.sorted().prefix(64))
|
||||
]
|
||||
|
||||
if let line = encodeLine(compact), line.utf8.count <= maxBytes {
|
||||
return compact
|
||||
}
|
||||
|
||||
compact["payload"] = [
|
||||
"truncated": true,
|
||||
"reason": "event exceeded max encoded byte limit",
|
||||
"max_bytes": maxBytes
|
||||
]
|
||||
return compact
|
||||
}
|
||||
|
||||
private static func truncatedString(_ value: String, maxUTF8Bytes: Int) -> String {
|
||||
guard value.utf8.count > maxUTF8Bytes else { return value }
|
||||
let suffix = "..."
|
||||
let budget = max(0, maxUTF8Bytes - suffix.utf8.count)
|
||||
var result = ""
|
||||
var used = 0
|
||||
for scalar in value.unicodeScalars {
|
||||
let scalarText = String(scalar)
|
||||
let scalarBytes = scalarText.utf8.count
|
||||
guard used + scalarBytes <= budget else { break }
|
||||
result.append(scalarText)
|
||||
used += scalarBytes
|
||||
}
|
||||
return result + suffix
|
||||
}
|
||||
|
||||
static func isoTimestamp(_ date: Date) -> String { isoFormatterLock.lock(); defer { isoFormatterLock.unlock() }; return isoFormatter.string(from: date) }
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
nonisolated private let cmuxEventLogLogger = Logger(subsystem: "com.cmuxterm.app", category: "event-log")
|
||||
|
||||
// Sendable safety: pending state is protected by `lock`; file IO runs on `queue`.
|
||||
final class CmuxEventLogWriter: @unchecked Sendable {
|
||||
static let defaultMaxPendingLines = 1_024
|
||||
|
||||
private static let queue = DispatchQueue(label: "com.cmuxterm.event-log", qos: .utility)
|
||||
|
||||
private let eventLogURL: URL
|
||||
private let maxEventLogBytes: UInt64
|
||||
private let maxPendingLines: Int
|
||||
private let lock = NSLock()
|
||||
private var pendingLines: [String] = []
|
||||
private var flushScheduled = false
|
||||
private var droppedLineCount = 0
|
||||
#if DEBUG
|
||||
private var flushSuspendedForTesting = false
|
||||
#endif
|
||||
|
||||
init(eventLogURL: URL, maxEventLogBytes: UInt64, maxPendingLines: Int) {
|
||||
self.eventLogURL = eventLogURL
|
||||
self.maxEventLogBytes = max(1, maxEventLogBytes)
|
||||
self.maxPendingLines = max(1, maxPendingLines)
|
||||
}
|
||||
|
||||
func enqueue(_ line: String) {
|
||||
var shouldSchedule = false
|
||||
lock.lock()
|
||||
if pendingLines.count >= maxPendingLines {
|
||||
let removedCount = pendingLines.count - maxPendingLines + 1
|
||||
pendingLines.removeFirst(removedCount)
|
||||
droppedLineCount += removedCount
|
||||
}
|
||||
pendingLines.append(line)
|
||||
#if DEBUG
|
||||
if flushSuspendedForTesting {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
if !flushScheduled {
|
||||
flushScheduled = true
|
||||
shouldSchedule = true
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
if shouldSchedule {
|
||||
Self.queue.async { [self] in flushPendingLines() }
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func flushForTesting() {
|
||||
scheduleFlushIfNeeded()
|
||||
Self.queue.sync {}
|
||||
}
|
||||
|
||||
func setFlushSuspendedForTesting(_ suspended: Bool) {
|
||||
lock.lock()
|
||||
flushSuspendedForTesting = suspended
|
||||
lock.unlock()
|
||||
if !suspended {
|
||||
scheduleFlushIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func backlogSnapshotForTesting() -> (pending: Int, dropped: Int) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (pendingLines.count, droppedLineCount)
|
||||
}
|
||||
|
||||
func resetForTesting() {
|
||||
lock.lock()
|
||||
pendingLines.removeAll()
|
||||
flushScheduled = false
|
||||
droppedLineCount = 0
|
||||
flushSuspendedForTesting = false
|
||||
lock.unlock()
|
||||
}
|
||||
#endif
|
||||
|
||||
private func scheduleFlushIfNeeded() {
|
||||
var shouldSchedule = false
|
||||
lock.lock()
|
||||
#if DEBUG
|
||||
guard !flushSuspendedForTesting else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
if !pendingLines.isEmpty, !flushScheduled {
|
||||
flushScheduled = true
|
||||
shouldSchedule = true
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
if shouldSchedule {
|
||||
Self.queue.async { [self] in flushPendingLines() }
|
||||
}
|
||||
}
|
||||
|
||||
private func flushPendingLines() {
|
||||
while true {
|
||||
let lines: [String]
|
||||
let droppedCount: Int
|
||||
lock.lock()
|
||||
if pendingLines.isEmpty {
|
||||
flushScheduled = false
|
||||
droppedCount = droppedLineCount
|
||||
droppedLineCount = 0
|
||||
lock.unlock()
|
||||
if droppedCount > 0 {
|
||||
cmuxEventLogLogger.warning("Dropped \(droppedCount, privacy: .public) cmux event log line(s) under disk backpressure")
|
||||
}
|
||||
return
|
||||
}
|
||||
lines = pendingLines
|
||||
pendingLines.removeAll(keepingCapacity: true)
|
||||
lock.unlock()
|
||||
append(lines)
|
||||
}
|
||||
}
|
||||
|
||||
private func append(_ lines: [String]) {
|
||||
guard !lines.isEmpty else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: eventLogURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let fileManager = FileManager.default
|
||||
if !fileManager.fileExists(atPath: eventLogURL.path) {
|
||||
_ = fileManager.createFile(atPath: eventLogURL.path, contents: nil)
|
||||
}
|
||||
var handle = try FileHandle(forWritingTo: eventLogURL)
|
||||
defer { try? handle.close() }
|
||||
try handle.seekToEnd()
|
||||
var currentSize = Self.fileSize(at: eventLogURL, fileManager: fileManager)
|
||||
for line in lines {
|
||||
let data = Data((line + "\n").utf8)
|
||||
if currentSize + UInt64(data.count) > maxEventLogBytes {
|
||||
try handle.close()
|
||||
try rotate(fileManager: fileManager)
|
||||
handle = try FileHandle(forWritingTo: eventLogURL)
|
||||
currentSize = 0
|
||||
}
|
||||
try handle.write(contentsOf: data)
|
||||
currentSize += UInt64(data.count)
|
||||
}
|
||||
} catch {
|
||||
cmuxEventLogLogger.error("Failed to append cmux event log: \(String(describing: error), privacy: .private)")
|
||||
}
|
||||
}
|
||||
|
||||
private func rotate(fileManager: FileManager) throws {
|
||||
let currentSize = Self.fileSize(at: eventLogURL, fileManager: fileManager)
|
||||
let rotatedURL = eventLogURL.appendingPathExtension("1")
|
||||
if Self.fileSize(at: rotatedURL, fileManager: fileManager) > maxEventLogBytes {
|
||||
try fileManager.removeItem(at: rotatedURL)
|
||||
}
|
||||
if currentSize > maxEventLogBytes {
|
||||
try fileManager.removeItem(at: eventLogURL)
|
||||
_ = fileManager.createFile(atPath: eventLogURL.path, contents: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if fileManager.fileExists(atPath: rotatedURL.path) {
|
||||
try fileManager.removeItem(at: rotatedURL)
|
||||
}
|
||||
if fileManager.fileExists(atPath: eventLogURL.path) {
|
||||
try fileManager.moveItem(at: eventLogURL, to: rotatedURL)
|
||||
}
|
||||
_ = fileManager.createFile(atPath: eventLogURL.path, contents: nil)
|
||||
}
|
||||
|
||||
private static func fileSize(at url: URL, fileManager: FileManager) -> UInt64 {
|
||||
guard let size = try? fileManager.attributesOfItem(atPath: url.path)[.size] as? NSNumber else {
|
||||
return 0
|
||||
}
|
||||
return size.uint64Value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import Foundation
|
||||
import CMUXWorkstream
|
||||
|
||||
extension CmuxEventBus {
|
||||
func publishWorkspaceCreated(
|
||||
workspaceId: UUID,
|
||||
title: String,
|
||||
customTitle: String?,
|
||||
currentDirectory: String,
|
||||
selected: Bool,
|
||||
index: Int?,
|
||||
tabCount: Int?
|
||||
) {
|
||||
publish(
|
||||
name: "workspace.created",
|
||||
category: "workspace",
|
||||
source: "workspace.lifecycle",
|
||||
workspaceId: workspaceId.uuidString,
|
||||
payload: workspacePayload(
|
||||
workspaceId: workspaceId,
|
||||
title: title,
|
||||
customTitle: customTitle,
|
||||
currentDirectory: currentDirectory,
|
||||
selected: selected,
|
||||
index: index,
|
||||
tabCount: tabCount
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func publishWorkspaceClosed(
|
||||
workspaceId: UUID,
|
||||
title: String,
|
||||
customTitle: String?,
|
||||
currentDirectory: String,
|
||||
remainingTabCount: Int?
|
||||
) {
|
||||
publish(
|
||||
name: "workspace.closed",
|
||||
category: "workspace",
|
||||
source: "workspace.lifecycle",
|
||||
workspaceId: workspaceId.uuidString,
|
||||
payload: workspacePayload(
|
||||
workspaceId: workspaceId,
|
||||
title: title,
|
||||
customTitle: customTitle,
|
||||
currentDirectory: currentDirectory,
|
||||
selected: false,
|
||||
index: nil,
|
||||
tabCount: remainingTabCount
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func publishWorkspaceSelected(
|
||||
workspaceId: UUID,
|
||||
title: String,
|
||||
customTitle: String?,
|
||||
currentDirectory: String,
|
||||
previousWorkspaceId: UUID?,
|
||||
index: Int?,
|
||||
tabCount: Int?
|
||||
) {
|
||||
publish(
|
||||
name: "workspace.selected",
|
||||
category: "workspace",
|
||||
source: "workspace.lifecycle",
|
||||
workspaceId: workspaceId.uuidString,
|
||||
payload: workspacePayload(
|
||||
workspaceId: workspaceId,
|
||||
title: title,
|
||||
customTitle: customTitle,
|
||||
currentDirectory: currentDirectory,
|
||||
selected: true,
|
||||
previousWorkspaceId: previousWorkspaceId,
|
||||
index: index,
|
||||
tabCount: tabCount
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func publishWindowLifecycle(
|
||||
name: String,
|
||||
windowId: UUID,
|
||||
workspaceId: UUID?,
|
||||
workspaceCount: Int?,
|
||||
selectedWorkspaceIndex: Int?,
|
||||
isKeyWindow: Bool?,
|
||||
isMainWindow: Bool?,
|
||||
origin: String
|
||||
) {
|
||||
publish(
|
||||
name: name,
|
||||
category: "window",
|
||||
source: "window.lifecycle",
|
||||
workspaceId: workspaceId?.uuidString,
|
||||
windowId: windowId.uuidString,
|
||||
payload: [
|
||||
"window_id": windowId.uuidString,
|
||||
"workspace_id": workspaceId?.uuidString ?? NSNull(),
|
||||
"workspace_count": workspaceCount ?? NSNull(),
|
||||
"selected_workspace_index": selectedWorkspaceIndex ?? NSNull(),
|
||||
"is_key_window": isKeyWindow ?? NSNull(),
|
||||
"is_main_window": isMainWindow ?? NSNull(),
|
||||
"origin": origin
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func publishPaneCreated(
|
||||
workspaceId: UUID,
|
||||
paneId: UUID,
|
||||
sourcePaneId: UUID?,
|
||||
orientation: String,
|
||||
surfaceId: UUID?,
|
||||
origin: String
|
||||
) {
|
||||
publish(
|
||||
name: "pane.created",
|
||||
category: "pane",
|
||||
source: "workspace.lifecycle",
|
||||
workspaceId: workspaceId.uuidString,
|
||||
surfaceId: surfaceId?.uuidString,
|
||||
paneId: paneId.uuidString,
|
||||
payload: [
|
||||
"pane_id": paneId.uuidString,
|
||||
"source_pane_id": sourcePaneId?.uuidString ?? NSNull(),
|
||||
"orientation": orientation,
|
||||
"surface_id": surfaceId?.uuidString ?? NSNull(),
|
||||
"origin": origin
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func publishSurfaceCreated(
|
||||
workspaceId: UUID,
|
||||
surfaceId: UUID,
|
||||
paneId: UUID?,
|
||||
kind: String,
|
||||
origin: String,
|
||||
focused: Bool
|
||||
) {
|
||||
publish(
|
||||
name: "surface.created",
|
||||
category: "surface",
|
||||
source: "workspace.lifecycle",
|
||||
workspaceId: workspaceId.uuidString,
|
||||
surfaceId: surfaceId.uuidString,
|
||||
paneId: paneId?.uuidString,
|
||||
payload: [
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"pane_id": paneId?.uuidString ?? NSNull(),
|
||||
"kind": kind,
|
||||
"origin": origin,
|
||||
"focused": focused
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func publishSurfaceSelected(
|
||||
workspaceId: UUID,
|
||||
surfaceId: UUID,
|
||||
paneId: UUID?,
|
||||
kind: String?,
|
||||
previousSurfaceId: UUID?,
|
||||
focused: Bool,
|
||||
origin: String
|
||||
) {
|
||||
publish(
|
||||
name: "surface.selected",
|
||||
category: "surface",
|
||||
source: "workspace.lifecycle",
|
||||
workspaceId: workspaceId.uuidString,
|
||||
surfaceId: surfaceId.uuidString,
|
||||
paneId: paneId?.uuidString,
|
||||
payload: [
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"pane_id": paneId?.uuidString ?? NSNull(),
|
||||
"kind": kind ?? NSNull(),
|
||||
"previous_surface_id": previousSurfaceId?.uuidString ?? NSNull(),
|
||||
"focused": focused,
|
||||
"origin": origin
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func publishSurfaceFocused(workspaceId: UUID, surfaceId: UUID, paneId: UUID?, kind: String?, origin: String) {
|
||||
publish(
|
||||
name: "surface.focused",
|
||||
category: "surface",
|
||||
source: "workspace.lifecycle",
|
||||
workspaceId: workspaceId.uuidString,
|
||||
surfaceId: surfaceId.uuidString,
|
||||
paneId: paneId?.uuidString,
|
||||
payload: [
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"pane_id": paneId?.uuidString ?? NSNull(),
|
||||
"kind": kind ?? NSNull(),
|
||||
"origin": origin
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func publishSurfaceClosed(workspaceId: UUID, surfaceId: UUID, paneId: UUID?, kind: String?, origin: String) {
|
||||
publish(
|
||||
name: "surface.closed",
|
||||
category: "surface",
|
||||
source: "workspace.lifecycle",
|
||||
workspaceId: workspaceId.uuidString,
|
||||
surfaceId: surfaceId.uuidString,
|
||||
paneId: paneId?.uuidString,
|
||||
payload: [
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"pane_id": paneId?.uuidString ?? NSNull(),
|
||||
"kind": kind ?? NSNull(),
|
||||
"origin": origin
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func publishPaneClosed(workspaceId: UUID, paneId: UUID, closedSurfaceIds: [UUID], origin: String) {
|
||||
publish(
|
||||
name: "pane.closed",
|
||||
category: "pane",
|
||||
source: "workspace.lifecycle",
|
||||
workspaceId: workspaceId.uuidString,
|
||||
paneId: paneId.uuidString,
|
||||
payload: [
|
||||
"pane_id": paneId.uuidString,
|
||||
"closed_surface_ids": closedSurfaceIds.map(\.uuidString),
|
||||
"origin": origin
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func publishPaneFocused(workspaceId: UUID, paneId: UUID, selectedSurfaceId: UUID?, origin: String) {
|
||||
publish(
|
||||
name: "pane.focused",
|
||||
category: "pane",
|
||||
source: "workspace.lifecycle",
|
||||
workspaceId: workspaceId.uuidString,
|
||||
surfaceId: selectedSurfaceId?.uuidString,
|
||||
paneId: paneId.uuidString,
|
||||
payload: [
|
||||
"pane_id": paneId.uuidString,
|
||||
"selected_surface_id": selectedSurfaceId?.uuidString ?? NSNull(),
|
||||
"origin": origin
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func publishNotificationChanges(oldValue: [TerminalNotification], newValue: [TerminalNotification]) {
|
||||
let oldById = Dictionary(uniqueKeysWithValues: oldValue.map { ($0.id, $0) })
|
||||
let newIds = Set(newValue.map(\.id))
|
||||
let removed = oldValue.filter { !newIds.contains($0.id) }
|
||||
for notification in removed {
|
||||
publishNotificationRemoved(notification)
|
||||
}
|
||||
for notification in newValue {
|
||||
if let old = oldById[notification.id] {
|
||||
if !old.isRead, notification.isRead {
|
||||
publishNotificationRead(
|
||||
ids: [notification.id.uuidString],
|
||||
workspaceId: notification.tabId,
|
||||
surfaceId: notification.surfaceId
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let replacedIds = removed
|
||||
.filter { $0.tabId == notification.tabId && $0.surfaceId == notification.surfaceId }
|
||||
.map { $0.id.uuidString }
|
||||
publishNotificationCreated(notification, delivery: "store", replacedNotificationIds: replacedIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func publishNotificationCreated(
|
||||
_ notification: TerminalNotification,
|
||||
delivery: String,
|
||||
replacedNotificationIds: [String]
|
||||
) {
|
||||
publishNotificationLifecycle(
|
||||
name: "notification.created",
|
||||
notification: notification,
|
||||
payload: [
|
||||
"delivery": delivery,
|
||||
"replaced_notification_ids": replacedNotificationIds
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func publishNotificationRead(ids: [String], workspaceId: UUID?, surfaceId: UUID?) {
|
||||
guard !ids.isEmpty else { return }
|
||||
publish(
|
||||
name: "notification.read",
|
||||
category: "notification",
|
||||
source: "notification.store",
|
||||
workspaceId: workspaceId?.uuidString,
|
||||
surfaceId: surfaceId?.uuidString,
|
||||
payload: [
|
||||
"notification_ids": ids,
|
||||
"count": ids.count
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func publishNotificationRemoved(_ notification: TerminalNotification) {
|
||||
publishNotificationLifecycle(
|
||||
name: "notification.removed",
|
||||
notification: notification
|
||||
)
|
||||
}
|
||||
|
||||
func publishNotificationCleared(ids: [String], workspaceId: UUID?, surfaceId: UUID?) {
|
||||
guard !ids.isEmpty else { return }
|
||||
publish(
|
||||
name: "notification.cleared",
|
||||
category: "notification",
|
||||
source: "notification.store",
|
||||
workspaceId: workspaceId?.uuidString,
|
||||
surfaceId: surfaceId?.uuidString,
|
||||
payload: [
|
||||
"notification_ids": ids,
|
||||
"count": ids.count
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private func publishNotificationLifecycle(
|
||||
name: String,
|
||||
notification: TerminalNotification,
|
||||
payload extraPayload: [String: Any] = [:]
|
||||
) {
|
||||
var payload = CmuxSocketEventMapper.redactedNotificationParams([
|
||||
"notification_id": notification.id.uuidString,
|
||||
"workspace_id": notification.tabId.uuidString,
|
||||
"surface_id": notification.surfaceId?.uuidString ?? NSNull(),
|
||||
"title": notification.title,
|
||||
"subtitle": notification.subtitle,
|
||||
"body": notification.body,
|
||||
"created_at": notification.createdAt,
|
||||
"is_read": notification.isRead
|
||||
])
|
||||
extraPayload.forEach { payload[$0.key] = $0.value }
|
||||
publish(
|
||||
name: name,
|
||||
category: "notification",
|
||||
source: "notification.store",
|
||||
workspaceId: notification.tabId.uuidString,
|
||||
surfaceId: notification.surfaceId?.uuidString,
|
||||
payload: payload
|
||||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable:next discouraged_optional_collection
|
||||
func publishWorkstreamEvent(_ event: WorkstreamEvent, phase: String, result: [String: Any]? = nil) {
|
||||
var payload = Self.workstreamPayload(event)
|
||||
payload["phase"] = phase
|
||||
if let result {
|
||||
payload["result"] = result
|
||||
}
|
||||
|
||||
publish(
|
||||
name: "agent.hook.\(event.hookEventName.rawValue)",
|
||||
category: "agent",
|
||||
source: event.source,
|
||||
workspaceId: event.workspaceId,
|
||||
payload: payload
|
||||
)
|
||||
|
||||
publish(
|
||||
name: "feed.item.\(phase)",
|
||||
category: "feed",
|
||||
source: event.source,
|
||||
workspaceId: event.workspaceId,
|
||||
payload: payload
|
||||
)
|
||||
}
|
||||
|
||||
static func workstreamPayload(_ event: WorkstreamEvent) -> [String: Any] {
|
||||
var payload: [String: Any] = [
|
||||
"session_id": event.sessionId,
|
||||
"hook_event_name": event.hookEventName.rawValue,
|
||||
"_source": event.source,
|
||||
"workspace_id": event.workspaceId ?? NSNull(),
|
||||
"cwd": event.cwd ?? NSNull(),
|
||||
"tool_name": event.toolName ?? NSNull(),
|
||||
"_opencode_request_id": event.requestId ?? NSNull(),
|
||||
"_ppid": event.ppid ?? NSNull(),
|
||||
"_received_at": Self.isoTimestamp(event.receivedAt)
|
||||
]
|
||||
var redactedFields: [String] = []
|
||||
if let toolInputJSON = event.toolInputJSON {
|
||||
payload["tool_input"] = NSNull()
|
||||
payload["tool_input_length"] = toolInputJSON.count
|
||||
redactedFields.append("tool_input")
|
||||
}
|
||||
if let context = event.context, !context.isEmpty {
|
||||
payload["context"] = NSNull()
|
||||
if let contextLength = encodedByteCount(context) {
|
||||
payload["context_length"] = contextLength
|
||||
}
|
||||
redactedFields.append("context")
|
||||
}
|
||||
if let extraFieldsJSON = event.extraFieldsJSON {
|
||||
payload["extra_fields"] = NSNull()
|
||||
payload["extra_fields_length"] = extraFieldsJSON.count
|
||||
redactedFields.append("extra_fields")
|
||||
}
|
||||
if !redactedFields.isEmpty {
|
||||
payload["redacted_fields"] = redactedFields
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private static func encodedByteCount<T: Encodable>(_ value: T) -> Int? {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
return try? encoder.encode(value).count
|
||||
}
|
||||
|
||||
private func workspacePayload(
|
||||
workspaceId: UUID,
|
||||
title: String,
|
||||
customTitle: String?,
|
||||
currentDirectory: String,
|
||||
selected: Bool,
|
||||
previousWorkspaceId: UUID? = nil,
|
||||
index: Int?,
|
||||
tabCount: Int?
|
||||
) -> [String: Any] {
|
||||
[
|
||||
"workspace_id": workspaceId.uuidString,
|
||||
"title": title,
|
||||
"custom_title": customTitle ?? NSNull(),
|
||||
"cwd": currentDirectory,
|
||||
"selected": selected,
|
||||
"previous_workspace_id": previousWorkspaceId?.uuidString ?? NSNull(),
|
||||
"index": index ?? NSNull(),
|
||||
"tab_count": tabCount ?? NSNull()
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
extension TerminalController {
|
||||
nonisolated func isEventsStreamRequest(_ line: String) -> Bool {
|
||||
guard line.hasPrefix("{"),
|
||||
let data = line.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let method = object["method"] as? String else {
|
||||
return false
|
||||
}
|
||||
return method == "events.stream"
|
||||
}
|
||||
|
||||
nonisolated func handleEventsStreamRequest(_ line: String, socket: Int32) {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
_ = writeEventsStreamLine([
|
||||
"type": "error",
|
||||
"ok": false,
|
||||
"error": ["code": "invalid_request", "message": "events.stream requires a JSON object"]
|
||||
], socket: socket)
|
||||
return
|
||||
}
|
||||
|
||||
let params = object["params"] as? [String: Any] ?? [:]
|
||||
let afterSequence = CmuxEventBus.int64(params["after_seq"] ?? params["after"])
|
||||
let names = Self.stringSet(params["names"] ?? params["name"])
|
||||
let categories = Self.stringSet(params["categories"] ?? params["category"])
|
||||
let includeHeartbeats = Self.boolParam(params["include_heartbeats"] ?? params["include_heartbeat"]) ?? true
|
||||
|
||||
let snapshot = CmuxEventBus.shared.subscribe(
|
||||
afterSequence: afterSequence,
|
||||
names: names,
|
||||
categories: categories
|
||||
)
|
||||
defer { CmuxEventBus.shared.unsubscribe(snapshot.subscription) }
|
||||
|
||||
guard writeEventsStreamLine(snapshot.ack, socket: socket) else { return }
|
||||
for event in snapshot.replay {
|
||||
guard writeEventsStreamLine(event, socket: socket) else { return }
|
||||
}
|
||||
|
||||
while true {
|
||||
if let event = snapshot.subscription.next(timeout: CmuxEventBus.defaultHeartbeatIntervalSeconds) {
|
||||
guard writeEventsStreamLine(event, socket: socket) else { return }
|
||||
} else if snapshot.subscription.isClosed {
|
||||
if let reason = snapshot.subscription.closeReason {
|
||||
_ = writeEventsStreamLine([
|
||||
"type": "error",
|
||||
"ok": false,
|
||||
"error": [
|
||||
"code": "slow_consumer",
|
||||
"message": reason,
|
||||
"latest_seq": NSNumber(value: CmuxEventBus.shared.latestSequence)
|
||||
]
|
||||
], socket: socket)
|
||||
}
|
||||
return
|
||||
} else if includeHeartbeats {
|
||||
let heartbeat = CmuxEventBus.shared.heartbeat(subscription: snapshot.subscription)
|
||||
guard writeEventsStreamLine(heartbeat, socket: socket) else { return }
|
||||
} else if Self.socketPeerClosed(socket) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func publishSocketEvents(command: String, response: String) {
|
||||
CmuxSocketEventMapper.publish(command: command, response: response)
|
||||
}
|
||||
|
||||
private nonisolated func writeEventsStreamLine(_ object: [String: Any], socket: Int32) -> Bool {
|
||||
guard let line = CmuxEventBus.encodeLine(object) else { return false }
|
||||
return Self.writeAllToSocket(Data((line + "\n").utf8), to: socket)
|
||||
}
|
||||
|
||||
private nonisolated static func stringSet(_ value: Any?) -> Set<String> {
|
||||
if let string = value as? String {
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? [] : [trimmed]
|
||||
}
|
||||
if let values = value as? [String] {
|
||||
return Set(values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty })
|
||||
}
|
||||
if let values = value as? [Any] {
|
||||
return Set(values.compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty })
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private nonisolated static func boolParam(_ value: Any?) -> Bool? {
|
||||
if let number = value as? NSNumber {
|
||||
if CFGetTypeID(number) == CFBooleanGetTypeID() { return number.boolValue }
|
||||
if number.compare(NSNumber(value: 0)) == .orderedSame { return false }
|
||||
if number.compare(NSNumber(value: 1)) == .orderedSame { return true }
|
||||
return nil
|
||||
}
|
||||
guard let string = value as? String else { return nil }
|
||||
switch string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "true", "1": return true
|
||||
case "false", "0": return false
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func socketPeerClosed(_ socket: Int32) -> Bool {
|
||||
var byte: UInt8 = 0
|
||||
let result = recv(socket, &byte, 1, MSG_PEEK | MSG_DONTWAIT)
|
||||
if result == 0 {
|
||||
return true
|
||||
}
|
||||
if result > 0 {
|
||||
return false
|
||||
}
|
||||
let errorCode = errno
|
||||
return errorCode != EAGAIN && errorCode != EWOULDBLOCK && errorCode != EINTR
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Bonsplit
|
||||
|
||||
@MainActor
|
||||
private enum CmuxSelectionEventState {
|
||||
static var selectedSurfaceByWorkspacePane: [String: UUID] = [:]
|
||||
static var focusedPaneByWorkspace: [UUID: UUID] = [:]
|
||||
static var focusedSurfaceByWorkspace: [UUID: UUID] = [:]
|
||||
|
||||
static func paneKey(workspaceId: UUID, paneId: UUID) -> String {
|
||||
"\(workspaceId.uuidString):\(paneId.uuidString)"
|
||||
}
|
||||
|
||||
static func clearWorkspace(_ workspaceId: UUID) {
|
||||
selectedSurfaceByWorkspacePane = selectedSurfaceByWorkspacePane.filter {
|
||||
!$0.key.hasPrefix("\(workspaceId.uuidString):")
|
||||
}
|
||||
focusedPaneByWorkspace.removeValue(forKey: workspaceId)
|
||||
focusedSurfaceByWorkspace.removeValue(forKey: workspaceId)
|
||||
}
|
||||
|
||||
static func clearPane(workspaceId: UUID, paneId: UUID) {
|
||||
selectedSurfaceByWorkspacePane.removeValue(forKey: paneKey(workspaceId: workspaceId, paneId: paneId))
|
||||
if focusedPaneByWorkspace[workspaceId] == paneId {
|
||||
focusedPaneByWorkspace.removeValue(forKey: workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
static func clearSurface(workspaceId: UUID, surfaceId: UUID) {
|
||||
selectedSurfaceByWorkspacePane = selectedSurfaceByWorkspacePane.filter { $0.value != surfaceId }
|
||||
if focusedSurfaceByWorkspace[workspaceId] == surfaceId {
|
||||
focusedSurfaceByWorkspace.removeValue(forKey: workspaceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TabManager {
|
||||
func publishCmuxWorkspaceCreated(_ workspace: Workspace, selected: Bool) {
|
||||
CmuxEventBus.shared.publishWorkspaceCreated(
|
||||
workspaceId: workspace.id,
|
||||
title: workspace.cmuxEventWorkspaceTitle,
|
||||
customTitle: workspace.customTitle,
|
||||
currentDirectory: workspace.currentDirectory,
|
||||
selected: selected,
|
||||
index: tabs.firstIndex(where: { $0.id == workspace.id }),
|
||||
tabCount: tabs.count
|
||||
)
|
||||
}
|
||||
|
||||
func publishCmuxInitialSurfaceCreated(_ workspace: Workspace, selected: Bool) {
|
||||
guard let terminalPanel = workspace.focusedTerminalPanel else { return }
|
||||
workspace.publishCmuxSurfaceCreated(
|
||||
terminalPanel.id,
|
||||
paneId: workspace.paneId(forPanelId: terminalPanel.id),
|
||||
kind: "terminal",
|
||||
origin: "workspace_initial",
|
||||
focused: selected
|
||||
)
|
||||
}
|
||||
|
||||
func publishCmuxWorkspaceClosed(_ workspace: Workspace) {
|
||||
CmuxEventBus.shared.publishWorkspaceClosed(
|
||||
workspaceId: workspace.id,
|
||||
title: workspace.cmuxEventWorkspaceTitle,
|
||||
customTitle: workspace.customTitle,
|
||||
currentDirectory: workspace.currentDirectory,
|
||||
remainingTabCount: tabs.count
|
||||
)
|
||||
CmuxSelectionEventState.clearWorkspace(workspace.id)
|
||||
}
|
||||
|
||||
func publishCmuxWorkspaceSelected(_ workspace: Workspace) {
|
||||
CmuxEventBus.shared.publishWorkspaceSelected(
|
||||
workspaceId: workspace.id,
|
||||
title: workspace.cmuxEventWorkspaceTitle,
|
||||
customTitle: workspace.customTitle,
|
||||
currentDirectory: workspace.currentDirectory,
|
||||
previousWorkspaceId: nil,
|
||||
index: tabs.firstIndex(where: { $0.id == workspace.id }),
|
||||
tabCount: tabs.count
|
||||
)
|
||||
}
|
||||
|
||||
func publishCmuxWorkspaceSelectedChange(from previousWorkspaceId: UUID?) {
|
||||
guard let selectedTabId,
|
||||
let workspace = tabs.first(where: { $0.id == selectedTabId }) else { return }
|
||||
CmuxEventBus.shared.publishWorkspaceSelected(
|
||||
workspaceId: workspace.id,
|
||||
title: workspace.cmuxEventWorkspaceTitle,
|
||||
customTitle: workspace.customTitle,
|
||||
currentDirectory: workspace.currentDirectory,
|
||||
previousWorkspaceId: previousWorkspaceId,
|
||||
index: tabs.firstIndex(where: { $0.id == workspace.id }),
|
||||
tabCount: tabs.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Workspace {
|
||||
var cmuxEventWorkspaceTitle: String {
|
||||
customTitle ?? title
|
||||
}
|
||||
|
||||
func publishCmuxSplitCreated(
|
||||
_ paneId: PaneID,
|
||||
sourcePaneId: PaneID?,
|
||||
orientation: SplitOrientation,
|
||||
surfaceId: UUID?,
|
||||
kind: String,
|
||||
origin: String,
|
||||
focused: Bool
|
||||
) {
|
||||
CmuxEventBus.shared.publishPaneCreated(
|
||||
workspaceId: id,
|
||||
paneId: paneId.id,
|
||||
sourcePaneId: sourcePaneId?.id,
|
||||
orientation: orientation.rawValue,
|
||||
surfaceId: surfaceId,
|
||||
origin: origin
|
||||
)
|
||||
if let surfaceId {
|
||||
publishCmuxSurfaceCreated(surfaceId, paneId: paneId, kind: kind, origin: origin, focused: focused)
|
||||
}
|
||||
}
|
||||
|
||||
func publishCmuxSurfaceCreated(
|
||||
_ surfaceId: UUID,
|
||||
paneId: PaneID?,
|
||||
kind: String,
|
||||
origin: String,
|
||||
focused: Bool
|
||||
) {
|
||||
CmuxEventBus.shared.publishSurfaceCreated(
|
||||
workspaceId: id,
|
||||
surfaceId: surfaceId,
|
||||
paneId: paneId?.id,
|
||||
kind: kind,
|
||||
origin: origin,
|
||||
focused: focused
|
||||
)
|
||||
}
|
||||
|
||||
func publishCmuxSurfaceClosed(_ surfaceId: UUID, paneId: PaneID?, panel: (any Panel)?, origin: String) {
|
||||
CmuxEventBus.shared.publishSurfaceClosed(
|
||||
workspaceId: id,
|
||||
surfaceId: surfaceId,
|
||||
paneId: paneId?.id,
|
||||
kind: panel.map(Self.cmuxEventSurfaceKind),
|
||||
origin: origin
|
||||
)
|
||||
CmuxSelectionEventState.clearSurface(workspaceId: id, surfaceId: surfaceId)
|
||||
}
|
||||
|
||||
func publishCmuxPaneClosed(_ paneId: PaneID, closedPanelIds: [UUID], origin: String) {
|
||||
CmuxEventBus.shared.publishPaneClosed(
|
||||
workspaceId: id,
|
||||
paneId: paneId.id,
|
||||
closedSurfaceIds: closedPanelIds,
|
||||
origin: origin
|
||||
)
|
||||
CmuxSelectionEventState.clearPane(workspaceId: id, paneId: paneId.id)
|
||||
}
|
||||
|
||||
func publishCmuxFocusedSelection(paneId: PaneID, surfaceId: UUID, origin: String) {
|
||||
let paneKey = CmuxSelectionEventState.paneKey(workspaceId: id, paneId: paneId.id)
|
||||
let previousSelectedSurfaceId = CmuxSelectionEventState.selectedSurfaceByWorkspacePane[paneKey]
|
||||
let kind = panels[surfaceId].map(Self.cmuxEventSurfaceKind)
|
||||
|
||||
if previousSelectedSurfaceId != surfaceId {
|
||||
CmuxSelectionEventState.selectedSurfaceByWorkspacePane[paneKey] = surfaceId
|
||||
CmuxEventBus.shared.publishSurfaceSelected(
|
||||
workspaceId: id,
|
||||
surfaceId: surfaceId,
|
||||
paneId: paneId.id,
|
||||
kind: kind,
|
||||
previousSurfaceId: previousSelectedSurfaceId,
|
||||
focused: true,
|
||||
origin: origin
|
||||
)
|
||||
}
|
||||
|
||||
if CmuxSelectionEventState.focusedPaneByWorkspace[id] != paneId.id {
|
||||
CmuxSelectionEventState.focusedPaneByWorkspace[id] = paneId.id
|
||||
CmuxEventBus.shared.publishPaneFocused(
|
||||
workspaceId: id,
|
||||
paneId: paneId.id,
|
||||
selectedSurfaceId: surfaceId,
|
||||
origin: origin
|
||||
)
|
||||
}
|
||||
|
||||
if CmuxSelectionEventState.focusedSurfaceByWorkspace[id] != surfaceId {
|
||||
CmuxSelectionEventState.focusedSurfaceByWorkspace[id] = surfaceId
|
||||
CmuxEventBus.shared.publishSurfaceFocused(
|
||||
workspaceId: id,
|
||||
surfaceId: surfaceId,
|
||||
paneId: paneId.id,
|
||||
kind: kind,
|
||||
origin: origin
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func cmuxEventSurfaceKind(_ panel: any Panel) -> String {
|
||||
switch panel.panelType {
|
||||
case .terminal:
|
||||
return "terminal"
|
||||
case .browser:
|
||||
return "browser"
|
||||
case .markdown:
|
||||
return "markdown"
|
||||
case .filePreview:
|
||||
return "file_preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum MainWindowKeyRegainRefresh {
|
||||
static func refresh(window: NSWindow, context: AppDelegate.MainWindowContext) {
|
||||
// Window focus regain owns the redraw invariant. Cursor tracking and
|
||||
// focused subviews can update themselves only after this invalidation.
|
||||
invalidateContentDisplayTree(window: window)
|
||||
_ = context.keyboardFocusCoordinator.restoreTargetAfterWindowBecameKey()
|
||||
}
|
||||
|
||||
private static func invalidateContentDisplayTree(window: NSWindow) {
|
||||
guard let contentView = window.contentView else { return }
|
||||
invalidateDisplayTree(rootedAt: contentView)
|
||||
window.invalidateCursorRects(for: contentView)
|
||||
}
|
||||
|
||||
private static func invalidateDisplayTree(rootedAt view: NSView) {
|
||||
guard !view.isHidden else { return }
|
||||
view.needsDisplay = true
|
||||
view.layer?.setNeedsDisplay()
|
||||
for subview in view.subviews {
|
||||
invalidateDisplayTree(rootedAt: subview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate {
|
||||
func handleCmuxWindowBecameKey(_ note: Notification) {
|
||||
guard let window = note.object as? NSWindow else { return }
|
||||
MainActor.assumeIsolated {
|
||||
let context = contextForMainTerminalWindow(window)
|
||||
setActiveMainWindow(window)
|
||||
if let windowId = mainWindowId(from: window) {
|
||||
publishCmuxWindowLifecycle(name: "window.keyed", windowId: windowId, origin: "appkit_key")
|
||||
}
|
||||
if let context {
|
||||
MainWindowKeyRegainRefresh.refresh(window: window, context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleCmuxWindowResignedKey(_ note: Notification) {
|
||||
guard let window = note.object as? NSWindow else { return }
|
||||
MainActor.assumeIsolated {
|
||||
if let windowId = mainWindowId(from: window) {
|
||||
publishCmuxWindowLifecycle(name: "window.unkeyed", windowId: windowId, origin: "appkit_key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func publishCmuxWindowLifecycle(name: String, windowId: UUID, origin: String) {
|
||||
let manager = tabManagerFor(windowId: windowId)
|
||||
let workspaceId = manager?.selectedTabId
|
||||
let selectedWorkspaceIndex = workspaceId.flatMap { selectedId in
|
||||
manager?.tabs.firstIndex(where: { $0.id == selectedId })
|
||||
}
|
||||
let window = mainWindow(for: windowId)
|
||||
CmuxEventBus.shared.publishWindowLifecycle(
|
||||
name: name,
|
||||
windowId: windowId,
|
||||
workspaceId: workspaceId,
|
||||
workspaceCount: manager?.tabs.count,
|
||||
selectedWorkspaceIndex: selectedWorkspaceIndex,
|
||||
isKeyWindow: window?.isKeyWindow,
|
||||
isMainWindow: window?.isMainWindow,
|
||||
origin: origin
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import Foundation
|
||||
|
||||
enum CmuxSSHURLParseError: Error, Equatable {
|
||||
case missingDestination
|
||||
case destinationTooLong(maxLength: Int)
|
||||
case destinationContainsUnsafeCharacters
|
||||
case destinationStartsWithDash
|
||||
case titleTooLong(maxLength: Int)
|
||||
case titleContainsUnsafeCharacters
|
||||
case invalidPort
|
||||
case invalidIntegerParameter(String)
|
||||
case invalidHostKeyPolicy(String)
|
||||
case invalidBooleanParameter(String)
|
||||
case conflictingDestinationParameters
|
||||
case conflictingTitleParameters
|
||||
case duplicateParameter(String)
|
||||
case unsupportedParameter(String)
|
||||
case multipleLinks
|
||||
}
|
||||
|
||||
struct CmuxSSHURLRequest: Equatable {
|
||||
static let maxDestinationLength = 256
|
||||
static let maxTitleLength = 160
|
||||
static let supportedSchemes: Set<String> = ["cmux", "cmux-nightly", "cmux-dev"]
|
||||
static var activeSupportedSchemes: Set<String> {
|
||||
[AuthEnvironment.callbackScheme.lowercased()]
|
||||
}
|
||||
|
||||
let originalURL: URL
|
||||
let destination: String
|
||||
let port: Int?
|
||||
let title: String?
|
||||
let sshOptions: [String]
|
||||
let noFocus: Bool
|
||||
|
||||
var cliArguments: [String] {
|
||||
var parts = ["ssh"]
|
||||
if let port {
|
||||
parts += ["--port", String(port)]
|
||||
}
|
||||
if let title = normalizedTitle {
|
||||
parts += ["--name", title]
|
||||
}
|
||||
for sshOption in sshOptions {
|
||||
parts += ["--ssh-option", sshOption]
|
||||
}
|
||||
if noFocus {
|
||||
parts.append("--no-focus")
|
||||
}
|
||||
parts.append(destination)
|
||||
return parts
|
||||
}
|
||||
|
||||
var cliPreview: String {
|
||||
cliPreview(socketPath: nil)
|
||||
}
|
||||
|
||||
func cliPreview(socketPath: String?) -> String {
|
||||
var parts = ["cmux"]
|
||||
if let socketPath, !socketPath.isEmpty {
|
||||
parts += ["--socket", socketPath]
|
||||
}
|
||||
parts += cliArguments
|
||||
return parts.map(Self.previewArgument).joined(separator: " ")
|
||||
}
|
||||
|
||||
var displayTarget: String {
|
||||
if let port {
|
||||
return "\(destination):\(port)"
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
private var normalizedTitle: String? {
|
||||
guard let title else { return nil }
|
||||
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func parse(
|
||||
_ url: URL,
|
||||
supportedSchemes: Set<String> = activeSupportedSchemes
|
||||
) -> Result<CmuxSSHURLRequest?, CmuxSSHURLParseError> {
|
||||
guard isSupportedScheme(url.scheme, supportedSchemes: supportedSchemes) else {
|
||||
return .success(nil)
|
||||
}
|
||||
guard sshTarget(from: url) else {
|
||||
return .success(nil)
|
||||
}
|
||||
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return .failure(.missingDestination)
|
||||
}
|
||||
|
||||
let queryItems = components.queryItems ?? []
|
||||
let allowedQueryNames: Set<String> = [
|
||||
"host",
|
||||
"user",
|
||||
"port",
|
||||
"title",
|
||||
"name",
|
||||
"connect-timeout",
|
||||
"server-alive-interval",
|
||||
"server-alive-count-max",
|
||||
"host-key-policy",
|
||||
"no-focus"
|
||||
]
|
||||
var seenQueryNames = Set<String>()
|
||||
for item in queryItems {
|
||||
let name = item.name.lowercased()
|
||||
guard allowedQueryNames.contains(name) else {
|
||||
return .failure(.unsupportedParameter(displayParameterName(item.name)))
|
||||
}
|
||||
guard seenQueryNames.insert(name).inserted else {
|
||||
return .failure(.duplicateParameter(displayParameterName(item.name)))
|
||||
}
|
||||
}
|
||||
guard !containsPathDestination(url) else {
|
||||
return .failure(.conflictingDestinationParameters)
|
||||
}
|
||||
|
||||
guard let hostValue = normalizedQueryValue(namedAnyOf: ["host"], in: queryItems) else {
|
||||
return .failure(.missingDestination)
|
||||
}
|
||||
guard !hostValue.hasPrefix("-") else {
|
||||
return .failure(.destinationStartsWithDash)
|
||||
}
|
||||
guard isAllowedSSHHost(hostValue) else {
|
||||
return .failure(.destinationContainsUnsafeCharacters)
|
||||
}
|
||||
|
||||
let userValue = normalizedQueryValue(namedAnyOf: ["user"], in: queryItems)
|
||||
if let userValue {
|
||||
guard !userValue.hasPrefix("-") else {
|
||||
return .failure(.destinationStartsWithDash)
|
||||
}
|
||||
guard isAllowedSSHUser(userValue) else {
|
||||
return .failure(.destinationContainsUnsafeCharacters)
|
||||
}
|
||||
}
|
||||
let destination = userValue.map { "\($0)@\(hostValue)" } ?? hostValue
|
||||
|
||||
guard destination.count <= maxDestinationLength else {
|
||||
return .failure(.destinationTooLong(maxLength: maxDestinationLength))
|
||||
}
|
||||
|
||||
let parsedPort: Int?
|
||||
if let portValue = normalizedQueryValue(namedAnyOf: ["port"], in: queryItems) {
|
||||
guard let value = Int(portValue), value > 0, value <= 65535 else {
|
||||
return .failure(.invalidPort)
|
||||
}
|
||||
parsedPort = value
|
||||
} else {
|
||||
parsedPort = nil
|
||||
}
|
||||
|
||||
let titleValue = normalizedQueryValue(namedAnyOf: ["title"], in: queryItems)
|
||||
let nameValue = normalizedQueryValue(namedAnyOf: ["name"], in: queryItems)
|
||||
guard titleValue == nil || nameValue == nil else {
|
||||
return .failure(.conflictingTitleParameters)
|
||||
}
|
||||
let title = titleValue ?? nameValue
|
||||
if let title {
|
||||
guard title.count <= maxTitleLength else {
|
||||
return .failure(.titleTooLong(maxLength: maxTitleLength))
|
||||
}
|
||||
guard !containsUnsafeHiddenCharacter(title) else {
|
||||
return .failure(.titleContainsUnsafeCharacters)
|
||||
}
|
||||
}
|
||||
|
||||
let sshOptions: [String]
|
||||
switch structuredSSHOptions(from: queryItems) {
|
||||
case .success(let options):
|
||||
sshOptions = options
|
||||
case .failure(let error):
|
||||
return .failure(error)
|
||||
}
|
||||
|
||||
let noFocus: Bool
|
||||
switch normalizedBooleanValue(named: "no-focus", in: queryItems) {
|
||||
case .success(let value):
|
||||
noFocus = value
|
||||
case .failure(let error):
|
||||
return .failure(error)
|
||||
}
|
||||
|
||||
return .success(
|
||||
CmuxSSHURLRequest(
|
||||
originalURL: url,
|
||||
destination: destination,
|
||||
port: parsedPort,
|
||||
title: title,
|
||||
sshOptions: sshOptions,
|
||||
noFocus: noFocus
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func isSupportedScheme(_ scheme: String?, supportedSchemes: Set<String>) -> Bool {
|
||||
guard let scheme = scheme?.lowercased() else { return false }
|
||||
return supportedSchemes.contains(scheme)
|
||||
}
|
||||
|
||||
private static func sshTarget(from url: URL) -> Bool {
|
||||
if let host = url.host?.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased(),
|
||||
!host.isEmpty {
|
||||
return host == "ssh"
|
||||
}
|
||||
|
||||
let firstPathComponent = url.path
|
||||
.split(separator: "/")
|
||||
.first
|
||||
.map { String($0).lowercased() }
|
||||
return firstPathComponent == "ssh"
|
||||
}
|
||||
|
||||
private static func containsPathDestination(_ url: URL) -> Bool {
|
||||
if let host = url.host?.lowercased(), host == "ssh" {
|
||||
return !url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty
|
||||
}
|
||||
let pathComponents = url.path
|
||||
.split(separator: "/", omittingEmptySubsequences: true)
|
||||
.map(String.init)
|
||||
return pathComponents.first?.lowercased() == "ssh" && pathComponents.count > 1
|
||||
}
|
||||
|
||||
private static func normalizedQueryValue(namedAnyOf names: Set<String>, in queryItems: [URLQueryItem]) -> String? {
|
||||
guard let value = queryItems.first(where: { names.contains($0.name.lowercased()) })?.value else {
|
||||
return nil
|
||||
}
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return normalized.isEmpty ? nil : normalized
|
||||
}
|
||||
|
||||
private static func structuredSSHOptions(from queryItems: [URLQueryItem]) -> Result<[String], CmuxSSHURLParseError> {
|
||||
var options: [String] = []
|
||||
if let value = normalizedQueryValue(namedAnyOf: ["connect-timeout"], in: queryItems) {
|
||||
switch boundedInteger(value, parameter: "connect-timeout", range: 1...600) {
|
||||
case .success(let seconds):
|
||||
options.append("ConnectTimeout=\(seconds)")
|
||||
case .failure(let error):
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
if let value = normalizedQueryValue(namedAnyOf: ["server-alive-interval"], in: queryItems) {
|
||||
switch boundedInteger(value, parameter: "server-alive-interval", range: 1...3600) {
|
||||
case .success(let seconds):
|
||||
options.append("ServerAliveInterval=\(seconds)")
|
||||
case .failure(let error):
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
if let value = normalizedQueryValue(namedAnyOf: ["server-alive-count-max"], in: queryItems) {
|
||||
switch boundedInteger(value, parameter: "server-alive-count-max", range: 1...100) {
|
||||
case .success(let count):
|
||||
options.append("ServerAliveCountMax=\(count)")
|
||||
case .failure(let error):
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
if let value = normalizedQueryValue(namedAnyOf: ["host-key-policy"], in: queryItems) {
|
||||
switch value.lowercased() {
|
||||
case "accept-new":
|
||||
options.append("StrictHostKeyChecking=accept-new")
|
||||
case "ask":
|
||||
options.append("StrictHostKeyChecking=ask")
|
||||
case "strict", "yes":
|
||||
options.append("StrictHostKeyChecking=yes")
|
||||
default:
|
||||
return .failure(.invalidHostKeyPolicy("host-key-policy"))
|
||||
}
|
||||
}
|
||||
return .success(options)
|
||||
}
|
||||
|
||||
private static func boundedInteger(_ value: String, parameter: String, range: ClosedRange<Int>) -> Result<Int, CmuxSSHURLParseError> {
|
||||
guard !containsUnsafeHiddenCharacter(value),
|
||||
value.range(of: #"^[0-9]+$"#, options: .regularExpression) != nil,
|
||||
let integer = Int(value),
|
||||
range.contains(integer) else {
|
||||
return .failure(.invalidIntegerParameter(parameter))
|
||||
}
|
||||
return .success(integer)
|
||||
}
|
||||
|
||||
private static func normalizedBooleanValue(named name: String, in queryItems: [URLQueryItem]) -> Result<Bool, CmuxSSHURLParseError> {
|
||||
guard let item = queryItems.first(where: { $0.name.lowercased() == name }) else {
|
||||
return .success(false)
|
||||
}
|
||||
guard let rawValue = item.value else {
|
||||
return .success(true)
|
||||
}
|
||||
let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized.isEmpty {
|
||||
return .success(true)
|
||||
}
|
||||
switch normalized {
|
||||
case "1", "true", "yes", "on":
|
||||
return .success(true)
|
||||
case "0", "false", "no", "off":
|
||||
return .success(false)
|
||||
default:
|
||||
return .failure(.invalidBooleanParameter(displayParameterName(item.name)))
|
||||
}
|
||||
}
|
||||
|
||||
private static func isAllowedSSHHost(_ value: String) -> Bool {
|
||||
guard !containsUnsafeHiddenCharacter(value) else { return false }
|
||||
if value.hasPrefix("[") || value.hasSuffix("]") {
|
||||
guard value.hasPrefix("["), value.hasSuffix("]") else { return false }
|
||||
let inner = String(value.dropFirst().dropLast())
|
||||
guard !inner.isEmpty else { return false }
|
||||
let allowed = CharacterSet(charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz:.%")
|
||||
return inner.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._%-")
|
||||
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
private static func isAllowedSSHUser(_ value: String) -> Bool {
|
||||
guard !containsUnsafeHiddenCharacter(value) else { return false }
|
||||
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._%+=-")
|
||||
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
private static func containsUnsafeHiddenCharacter(_ value: String) -> Bool {
|
||||
value.unicodeScalars.contains { scalar in
|
||||
switch scalar.properties.generalCategory {
|
||||
case .control, .format, .lineSeparator, .paragraphSeparator:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func previewArgument(_ value: String) -> String {
|
||||
if value.range(of: #"[^A-Za-z0-9_./:=+@%\-\[\]]"#, options: .regularExpression) == nil {
|
||||
return value
|
||||
}
|
||||
let escaped = value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\"\(escaped)\""
|
||||
}
|
||||
|
||||
private static func displayParameterName(_ name: String) -> String {
|
||||
if name.isEmpty || containsUnsafeHiddenCharacter(name) {
|
||||
return "?"
|
||||
}
|
||||
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-")
|
||||
guard name.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
|
||||
return "?"
|
||||
}
|
||||
let prefix = String(name.prefix(64))
|
||||
return prefix.count == name.count ? name : "\(prefix)..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
enum SidebarWorkspaceDetailDefaults {
|
||||
static let showBranchDirectory = true
|
||||
static let showPullRequests = true
|
||||
static let showSSH = true
|
||||
static let showPorts = true
|
||||
static let showLog = true
|
||||
static let showProgress = true
|
||||
static let showCustomMetadata = true
|
||||
}
|
||||
|
||||
enum AutomationSettings {
|
||||
static let portBaseKey = "cmuxPortBase"
|
||||
static let portRangeKey = "cmuxPortRange"
|
||||
static let defaultPortBase = 9100
|
||||
static let defaultPortRange = 10
|
||||
}
|
||||
|
||||
extension CmuxSettingsFileStore {
|
||||
// Keep this in sync with the parser below and the web schema/docs. Settings UI rows
|
||||
// validate against this set so new persisted settings need an explicit cmux.json review.
|
||||
static let supportedSettingsJSONPaths: Set<String> = [
|
||||
"app.language",
|
||||
"app.appearance",
|
||||
"app.appIcon",
|
||||
"app.menuBarOnly",
|
||||
"app.newWorkspacePlacement",
|
||||
"app.minimalMode",
|
||||
"app.keepWorkspaceOpenWhenClosingLastSurface",
|
||||
"app.focusPaneOnFirstClick",
|
||||
"app.preferredEditor",
|
||||
"app.openMarkdownInCmuxViewer",
|
||||
"app.iMessageMode",
|
||||
"app.reorderOnNotification",
|
||||
"app.sendAnonymousTelemetry",
|
||||
"app.warnBeforeQuit",
|
||||
"app.warnBeforeClosingTab",
|
||||
"app.renameSelectsExistingName",
|
||||
"app.commandPaletteSearchesAllSurfaces",
|
||||
"terminal.showScrollBar",
|
||||
"terminal.autoResumeAgentSessions",
|
||||
"notifications.dockBadge",
|
||||
"notifications.showInMenuBar",
|
||||
"notifications.unreadPaneRing",
|
||||
"notifications.paneFlash",
|
||||
"notifications.sound",
|
||||
"notifications.customSoundFilePath",
|
||||
"notifications.command",
|
||||
"sidebar.hideAllDetails",
|
||||
"sidebar.branchLayout",
|
||||
"sidebar.showNotificationMessage",
|
||||
"sidebar.showBranchDirectory",
|
||||
"sidebar.showPullRequests",
|
||||
"sidebar.makePullRequestsClickable",
|
||||
"sidebar.openPullRequestLinksInCmuxBrowser",
|
||||
"sidebar.openPortLinksInCmuxBrowser",
|
||||
"sidebar.showSSH",
|
||||
"sidebar.showPorts",
|
||||
"sidebar.showLog",
|
||||
"sidebar.showProgress",
|
||||
"sidebar.showCustomMetadata",
|
||||
"workspaceColors.indicatorStyle",
|
||||
"workspaceColors.selectionColor",
|
||||
"workspaceColors.notificationBadgeColor",
|
||||
"workspaceColors.colors",
|
||||
"workspaceColors.paletteOverrides",
|
||||
"workspaceColors.customColors",
|
||||
"sidebarAppearance.matchTerminalBackground",
|
||||
"sidebarAppearance.tintColor",
|
||||
"sidebarAppearance.lightModeTintColor",
|
||||
"sidebarAppearance.darkModeTintColor",
|
||||
"sidebarAppearance.tintOpacity",
|
||||
"automation.socketControlMode",
|
||||
"automation.socketPassword",
|
||||
"automation.claudeCodeIntegration",
|
||||
"automation.claudeBinaryPath",
|
||||
"automation.cursorIntegration",
|
||||
"automation.geminiIntegration",
|
||||
"automation.portBase",
|
||||
"automation.portRange",
|
||||
"browser.defaultSearchEngine",
|
||||
"browser.showSearchSuggestions",
|
||||
"browser.theme",
|
||||
"browser.openTerminalLinksInCmuxBrowser",
|
||||
"browser.interceptTerminalOpenCommandInCmuxBrowser",
|
||||
"browser.hostsToOpenInEmbeddedBrowser",
|
||||
"browser.urlsToAlwaysOpenExternally",
|
||||
"browser.insecureHttpHostsAllowedInEmbeddedBrowser",
|
||||
"browser.showImportHintOnBlankTabs",
|
||||
"browser.reactGrabVersion",
|
||||
"shortcuts.bindings",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import Foundation
|
||||
|
||||
enum CmuxSocketEventMapper {
|
||||
static func publish(command: String, response: String) {
|
||||
if publishV2(command: command, response: response) {
|
||||
return
|
||||
}
|
||||
publishV1(command: command, response: response)
|
||||
}
|
||||
|
||||
private static func publishV2(command: String, response: String) -> Bool {
|
||||
guard command.hasPrefix("{"),
|
||||
let requestData = command.data(using: .utf8),
|
||||
let request = try? JSONSerialization.jsonObject(with: requestData) as? [String: Any],
|
||||
let method = request["method"] as? String else {
|
||||
return false
|
||||
}
|
||||
guard method != "events.stream" else { return true }
|
||||
|
||||
let responseObject: [String: Any]
|
||||
if let responseData = response.data(using: .utf8),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] {
|
||||
responseObject = parsed
|
||||
} else {
|
||||
responseObject = ["ok": false, "error": ["message": response]]
|
||||
}
|
||||
|
||||
guard (responseObject["ok"] as? Bool) == true else {
|
||||
return true
|
||||
}
|
||||
|
||||
let params = request["params"] as? [String: Any] ?? [:]
|
||||
let result = responseObject["result"] as? [String: Any] ?? [:]
|
||||
publishDomainEventForV2(method: method, params: params, result: result)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func publishDomainEventForV2(method: String, params: [String: Any], result: [String: Any]) {
|
||||
switch method {
|
||||
case "window.create", "window.focus", "window.close":
|
||||
break
|
||||
case "workspace.create", "workspace.select", "workspace.next", "workspace.previous", "workspace.last", "workspace.close":
|
||||
break
|
||||
case "workspace.rename":
|
||||
publishResult(name: "workspace.renamed", category: "workspace", method: method, params: params, result: result)
|
||||
case "workspace.reorder":
|
||||
publishResult(name: "workspace.reordered", category: "workspace", method: method, params: params, result: result)
|
||||
case "workspace.move_to_window":
|
||||
publishResult(name: "workspace.moved", category: "workspace", method: method, params: params, result: result)
|
||||
case "workspace.action":
|
||||
publishResult(name: "workspace.action", category: "workspace", method: method, params: params, result: result)
|
||||
case "surface.create", "surface.split", "browser.open_split", "markdown.open", "file.open":
|
||||
break
|
||||
case "surface.split_off", "surface.drag_to_split":
|
||||
publishResult(name: "pane.created", category: "pane", method: method, params: params, result: result)
|
||||
case "surface.focus":
|
||||
break
|
||||
case "surface.close":
|
||||
break
|
||||
case "surface.move":
|
||||
publishResult(name: "surface.moved", category: "surface", method: method, params: params, result: result)
|
||||
case "surface.reorder":
|
||||
publishResult(name: "surface.reordered", category: "surface", method: method, params: params, result: result)
|
||||
case "surface.action", "tab.action":
|
||||
publishResult(name: "surface.action", category: "surface", method: method, params: params, result: result)
|
||||
case "surface.send_text":
|
||||
publishResult(name: "surface.input_sent", category: "surface", method: method, params: redactedInputParams(params), result: result)
|
||||
case "surface.send_key":
|
||||
publishResult(name: "surface.key_sent", category: "surface", method: method, params: params, result: result)
|
||||
case "pane.create":
|
||||
break
|
||||
case "pane.focus", "pane.last":
|
||||
break
|
||||
case "pane.resize":
|
||||
publishResult(name: "pane.resized", category: "pane", method: method, params: params, result: result)
|
||||
case "pane.swap":
|
||||
publishResult(name: "pane.swapped", category: "pane", method: method, params: params, result: result)
|
||||
case "pane.break":
|
||||
publishResult(name: "pane.broken", category: "pane", method: method, params: params, result: result)
|
||||
case "pane.join":
|
||||
publishResult(name: "pane.joined", category: "pane", method: method, params: params, result: result)
|
||||
case "notification.create", "notification.create_for_caller", "notification.create_for_surface", "notification.create_for_target":
|
||||
publishResult(name: "notification.requested", category: "notification", method: method, params: redactedNotificationParams(params), result: result)
|
||||
case "notification.clear":
|
||||
publishResult(name: "notification.clear_requested", category: "notification", method: method, params: params, result: result)
|
||||
case "notification.dismiss":
|
||||
publishResult(name: "notification.dismiss_requested", category: "notification", method: method, params: params, result: result)
|
||||
case "notification.mark_read":
|
||||
publishResult(name: "notification.mark_read_requested", category: "notification", method: method, params: params, result: result)
|
||||
case "notification.open":
|
||||
publishResult(name: "notification.open_requested", category: "notification", method: method, params: params, result: result)
|
||||
case "notification.jump_to_unread":
|
||||
publishResult(name: "notification.jump_to_unread_requested", category: "notification", method: method, params: params, result: result)
|
||||
case "feed.permission.reply", "feed.question.reply", "feed.exit_plan.reply":
|
||||
publishResult(name: "feed.item.resolved", category: "feed", method: method, params: params, result: result)
|
||||
case "app.focus_override.set":
|
||||
publishResult(name: "app.focus_override.changed", category: "app", method: method, params: params, result: result)
|
||||
case "app.simulate_active":
|
||||
publishResult(name: "app.simulated_active", category: "app", method: method, params: params, result: result)
|
||||
case "browser.navigate", "browser.back", "browser.forward", "browser.reload":
|
||||
publishResult(name: "browser.navigation", category: "browser", method: method, params: params, result: result)
|
||||
case "browser.click", "browser.dblclick", "browser.hover", "browser.focus", "browser.press", "browser.keydown", "browser.keyup", "browser.check", "browser.uncheck", "browser.select", "browser.scroll", "browser.scroll_into_view":
|
||||
publishResult(name: "browser.interaction", category: "browser", method: method, params: params, result: result)
|
||||
case "browser.type", "browser.fill":
|
||||
publishResult(name: "browser.input", category: "browser", method: method, params: redactedInputParams(params), result: result)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private static func publishV1(command: String, response: String) {
|
||||
let parts = command.split(separator: " ", maxSplits: 1).map(String.init)
|
||||
guard let rawName = parts.first else { return }
|
||||
let name = rawName.lowercased()
|
||||
guard response == "OK" || response.hasPrefix("OK ") || response.hasPrefix("OK\n") || response.hasPrefix("OK:") else { return }
|
||||
let args = parts.count > 1 ? parts[1] : ""
|
||||
let payload: [String: Any] = ["command": name, "args": redactedV1Args(name: name, args: args)]
|
||||
|
||||
switch name {
|
||||
case "new_window", "focus_window", "close_window":
|
||||
break
|
||||
case "new_workspace", "select_workspace", "close_workspace", "new_split", "new_pane", "new_surface", "open_browser":
|
||||
break
|
||||
case "focus_surface", "focus_surface_by_panel", "focus_pane":
|
||||
break
|
||||
case "close_surface":
|
||||
break
|
||||
case "send", "send_surface":
|
||||
CmuxEventBus.shared.publish(name: "surface.input_sent", category: "surface", source: "socket.v1", payload: payload)
|
||||
case "send_key", "send_key_surface":
|
||||
CmuxEventBus.shared.publish(name: "surface.key_sent", category: "surface", source: "socket.v1", payload: payload)
|
||||
case "notify_surface":
|
||||
var payloadWithSurface = payload
|
||||
let surfaceId = firstUUID(in: args)
|
||||
payloadWithSurface["surface_id"] = surfaceId ?? NSNull()
|
||||
CmuxEventBus.shared.publish(
|
||||
name: "notification.requested",
|
||||
category: "notification",
|
||||
source: "socket.v1",
|
||||
surfaceId: surfaceId,
|
||||
payload: payloadWithSurface
|
||||
)
|
||||
case "notify", "notify_target", "notify_target_async":
|
||||
CmuxEventBus.shared.publish(name: "notification.requested", category: "notification", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
|
||||
case "clear_notifications":
|
||||
CmuxEventBus.shared.publish(name: "notification.clear_requested", category: "notification", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
|
||||
case "set_status", "report_meta", "report_meta_block":
|
||||
CmuxEventBus.shared.publish(name: "sidebar.metadata.updated", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
|
||||
case "clear_status", "clear_meta", "clear_meta_block":
|
||||
CmuxEventBus.shared.publish(name: "sidebar.metadata.cleared", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
|
||||
case "set_progress":
|
||||
CmuxEventBus.shared.publish(name: "sidebar.progress.updated", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
|
||||
case "clear_progress":
|
||||
CmuxEventBus.shared.publish(name: "sidebar.progress.cleared", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
|
||||
case "log":
|
||||
CmuxEventBus.shared.publish(name: "sidebar.log.appended", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
|
||||
case "clear_log":
|
||||
CmuxEventBus.shared.publish(name: "sidebar.log.cleared", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
|
||||
case "reset_sidebar":
|
||||
CmuxEventBus.shared.publish(name: "sidebar.reset", category: "sidebar", source: "socket.v1", workspaceId: firstUUID(in: args), payload: payload)
|
||||
case "reload_config":
|
||||
CmuxEventBus.shared.publish(name: "config.reloaded", category: "config", source: "socket.v1", payload: payload)
|
||||
case "set_app_focus":
|
||||
CmuxEventBus.shared.publish(name: "app.focus_override.changed", category: "app", source: "socket.v1", payload: payload)
|
||||
case "simulate_app_active":
|
||||
CmuxEventBus.shared.publish(name: "app.simulated_active", category: "app", source: "socket.v1", payload: payload)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private static func publishResult(name: String, category: String, method: String, params: [String: Any], result: [String: Any]) {
|
||||
let workspaceId = stringValue(result["workspace_id"] ?? params["workspace_id"])
|
||||
let surfaceId = stringValue(result["surface_id"] ?? params["surface_id"])
|
||||
let paneId = stringValue(result["pane_id"] ?? params["pane_id"])
|
||||
let windowId = stringValue(result["window_id"] ?? params["window_id"])
|
||||
CmuxEventBus.shared.publish(
|
||||
name: name,
|
||||
category: category,
|
||||
source: "socket.v2",
|
||||
workspaceId: workspaceId,
|
||||
surfaceId: surfaceId,
|
||||
paneId: paneId,
|
||||
windowId: windowId,
|
||||
payload: [
|
||||
"method": method,
|
||||
"params": params,
|
||||
"result": result
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private static func redactedInputParams(_ params: [String: Any]) -> [String: Any] {
|
||||
var out = params
|
||||
if let text = out["text"] as? String {
|
||||
out["text"] = NSNull()
|
||||
out["text_length"] = text.count
|
||||
out["redacted_fields"] = ["text"]
|
||||
}
|
||||
if let value = out["value"] as? String {
|
||||
out["value"] = NSNull()
|
||||
out["value_length"] = value.count
|
||||
out["redacted_fields"] = ((out["redacted_fields"] as? [String]) ?? []) + ["value"]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
static func redactedNotificationParams(_ params: [String: Any]) -> [String: Any] {
|
||||
var out = params
|
||||
var redactedFields = (out["redacted_fields"] as? [String]) ?? []
|
||||
for key in ["title", "subtitle", "body"] {
|
||||
if let text = out[key] as? String {
|
||||
out[key] = NSNull()
|
||||
out["\(key)_length"] = text.count
|
||||
if !redactedFields.contains(key) {
|
||||
redactedFields.append(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !redactedFields.isEmpty {
|
||||
out["redacted_fields"] = redactedFields
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func redactedV1Args(name: String, args: String) -> String {
|
||||
switch name {
|
||||
case "send", "send_surface", "notify", "notify_surface", "notify_target", "notify_target_async":
|
||||
return "<redacted>"
|
||||
default:
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
private static func firstUUID(in text: String) -> String? {
|
||||
for token in text.split(whereSeparator: { $0.isWhitespace }) {
|
||||
let cleaned = token.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||
if UUID(uuidString: cleaned) != nil {
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func stringValue(_ value: Any?) -> String? {
|
||||
if let string = value as? String, !string.isEmpty { return string }
|
||||
if let uuid = value as? UUID { return uuid.uuidString }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
struct CmuxTopProcessArguments: Sendable {
|
||||
let arguments: [String]
|
||||
let environment: [String: String]
|
||||
}
|
||||
|
||||
extension CmuxTopProcessSnapshot {
|
||||
static func processArgumentsAndEnvironment(for pid: Int) -> CmuxTopProcessArguments? {
|
||||
guard pid > 0, pid <= Int(Int32.max),
|
||||
let bytes = kernProcArgsBytes(for: pid) else {
|
||||
return nil
|
||||
}
|
||||
return processArgumentsAndEnvironment(fromKernProcArgs: bytes)
|
||||
}
|
||||
|
||||
static func processArgumentsAndEnvironment(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessArguments? {
|
||||
guard bytes.count > MemoryLayout<Int32>.size else { return nil }
|
||||
|
||||
var argcRaw: Int32 = 0
|
||||
withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in
|
||||
rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout<Int32>.size))
|
||||
}
|
||||
let argc = Int(Int32(littleEndian: argcRaw))
|
||||
guard argc > 0 else { return nil }
|
||||
|
||||
var index = MemoryLayout<Int32>.size
|
||||
skipString(in: bytes, index: &index)
|
||||
skipNulls(in: bytes, index: &index)
|
||||
|
||||
var arguments: [String] = []
|
||||
for _ in 0..<argc {
|
||||
guard index < bytes.count else { return nil }
|
||||
let start = index
|
||||
skipString(in: bytes, index: &index)
|
||||
if start < index,
|
||||
let argument = String(bytes: bytes[start..<index], encoding: .utf8) {
|
||||
arguments.append(argument)
|
||||
}
|
||||
skipNulls(in: bytes, index: &index)
|
||||
}
|
||||
|
||||
var environment: [String: String] = [:]
|
||||
while index < bytes.count {
|
||||
skipNulls(in: bytes, index: &index)
|
||||
guard index < bytes.count else { break }
|
||||
let start = index
|
||||
skipString(in: bytes, index: &index)
|
||||
guard start < index,
|
||||
let entry = String(bytes: bytes[start..<index], encoding: .utf8),
|
||||
let equals = entry.firstIndex(of: "=") else {
|
||||
continue
|
||||
}
|
||||
let key = String(entry[..<equals])
|
||||
guard !key.isEmpty else { continue }
|
||||
environment[key] = String(entry[entry.index(after: equals)...])
|
||||
}
|
||||
|
||||
return CmuxTopProcessArguments(arguments: arguments, environment: environment)
|
||||
}
|
||||
|
||||
private static func kernProcArgsBytes(for pid: Int) -> [UInt8]? {
|
||||
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)]
|
||||
var size: size_t = 0
|
||||
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0,
|
||||
size > MemoryLayout<Int32>.size else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var buffer = [UInt8](repeating: 0, count: size)
|
||||
let success = buffer.withUnsafeMutableBytes { rawBuffer in
|
||||
sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0
|
||||
}
|
||||
guard success else { return nil }
|
||||
return Array(buffer.prefix(Int(size)))
|
||||
}
|
||||
|
||||
private static func skipString(in bytes: [UInt8], index: inout Int) {
|
||||
while index < bytes.count, bytes[index] != 0 {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func skipNulls(in bytes: [UInt8], index: inout Int) {
|
||||
while index < bytes.count, bytes[index] == 0 {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
nonisolated struct CmuxTopProcessCPUSample: Sendable {
|
||||
let totalTimeTicks: UInt64
|
||||
let sampledAtNanoseconds: UInt64
|
||||
}
|
||||
|
||||
private nonisolated struct CmuxTopProcessCPUTrackerState: Sendable {
|
||||
var samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:]
|
||||
var latestPrunedAtNanoseconds: UInt64 = 0
|
||||
}
|
||||
|
||||
private nonisolated final class CmuxTopProcessCPUTracker: @unchecked Sendable {
|
||||
private let state = OSAllocatedUnfairLock(initialState: CmuxTopProcessCPUTrackerState())
|
||||
|
||||
// Snapshot capture is synchronous for the v2 socket path, so an actor would
|
||||
// force that caller to block on async state. Keep OS sampling outside this
|
||||
// owner and serialize only the CPU history read/compute/write transaction.
|
||||
func cpuPercentages(
|
||||
for currentSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample],
|
||||
activeKeys: Set<CmuxTopProcessScopeCacheKey>,
|
||||
sampledAtNanoseconds: UInt64
|
||||
) -> [CmuxTopProcessScopeCacheKey: Double] {
|
||||
state.withLock { state in
|
||||
var percentages: [CmuxTopProcessScopeCacheKey: Double] = [:]
|
||||
percentages.reserveCapacity(currentSamples.count)
|
||||
|
||||
for (key, sample) in currentSamples {
|
||||
let existing = state.samples[key]
|
||||
if let existing,
|
||||
existing.sampledAtNanoseconds > sample.sampledAtNanoseconds {
|
||||
continue
|
||||
}
|
||||
|
||||
percentages[key] = CmuxTopProcessSnapshot.cpuPercent(
|
||||
current: sample,
|
||||
previous: existing
|
||||
)
|
||||
state.samples[key] = sample
|
||||
}
|
||||
|
||||
// Overlapping captures can finish out of sample-time order; only
|
||||
// the newest completed capture is allowed to evict inactive keys.
|
||||
if sampledAtNanoseconds >= state.latestPrunedAtNanoseconds {
|
||||
state.latestPrunedAtNanoseconds = sampledAtNanoseconds
|
||||
state.samples = state.samples.filter { entry in
|
||||
activeKeys.contains(entry.key)
|
||||
}
|
||||
}
|
||||
|
||||
return percentages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated let cmuxTopProcessCPUTracker = CmuxTopProcessCPUTracker()
|
||||
private nonisolated let cmuxTopAbsoluteTimeNanosecondsRatio: Double? = {
|
||||
var info = mach_timebase_info_data_t()
|
||||
guard mach_timebase_info(&info) == KERN_SUCCESS, info.denom > 0 else {
|
||||
return nil
|
||||
}
|
||||
return Double(info.numer) / Double(info.denom)
|
||||
}()
|
||||
|
||||
nonisolated extension CmuxTopProcessSnapshot {
|
||||
static func cpuSampleClockNanoseconds() -> UInt64 {
|
||||
clock_gettime_nsec_np(CLOCK_UPTIME_RAW)
|
||||
}
|
||||
|
||||
static func cpuPercentages(
|
||||
for samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample],
|
||||
activeKeys: Set<CmuxTopProcessScopeCacheKey>,
|
||||
sampledAtNanoseconds: UInt64
|
||||
) -> [CmuxTopProcessScopeCacheKey: Double] {
|
||||
cmuxTopProcessCPUTracker.cpuPercentages(
|
||||
for: samples,
|
||||
activeKeys: activeKeys,
|
||||
sampledAtNanoseconds: sampledAtNanoseconds
|
||||
)
|
||||
}
|
||||
|
||||
static func cpuSample(
|
||||
from taskInfo: proc_taskinfo,
|
||||
sampledAtNanoseconds: UInt64
|
||||
) -> CmuxTopProcessCPUSample {
|
||||
CmuxTopProcessCPUSample(
|
||||
totalTimeTicks: clampedCPUTimeTicks(taskInfo.pti_total_user, taskInfo.pti_total_system),
|
||||
sampledAtNanoseconds: sampledAtNanoseconds
|
||||
)
|
||||
}
|
||||
|
||||
static func cpuPercent(
|
||||
current: CmuxTopProcessCPUSample,
|
||||
previous: CmuxTopProcessCPUSample?
|
||||
) -> Double {
|
||||
guard let previous,
|
||||
current.sampledAtNanoseconds > previous.sampledAtNanoseconds,
|
||||
current.totalTimeTicks >= previous.totalTimeTicks,
|
||||
current.totalTimeTicks != UInt64.max,
|
||||
previous.totalTimeTicks != UInt64.max else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let cpuDelta = current.totalTimeTicks - previous.totalTimeTicks
|
||||
let wallDeltaNanoseconds = current.sampledAtNanoseconds - previous.sampledAtNanoseconds
|
||||
guard wallDeltaNanoseconds > 0 else { return 0 }
|
||||
|
||||
guard let cpuNanoseconds = absoluteTimeNanoseconds(cpuDelta) else { return 0 }
|
||||
let wallNanoseconds = Double(wallDeltaNanoseconds)
|
||||
|
||||
return max(0, cpuNanoseconds / wallNanoseconds * 100.0)
|
||||
}
|
||||
|
||||
private static func clampedCPUTimeTicks(_ user: UInt64, _ system: UInt64) -> UInt64 {
|
||||
let (sum, overflow) = user.addingReportingOverflow(system)
|
||||
return overflow ? UInt64.max : sum
|
||||
}
|
||||
|
||||
private static func absoluteTimeNanoseconds(_ ticks: UInt64) -> Double? {
|
||||
guard let ratio = cmuxTopAbsoluteTimeNanosecondsRatio else { return nil }
|
||||
return Double(ticks) * ratio
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import ObjectiveC.runtime
|
||||
import WebKit
|
||||
|
||||
enum CmuxWebContentProcessIdentifier {
|
||||
@MainActor
|
||||
static func pid(for webView: WKWebView) -> Int? {
|
||||
let selector = NSSelectorFromString("_webProcessIdentifier")
|
||||
guard let method = class_getInstanceMethod(WKWebView.self, selector) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
typealias WebProcessIdentifierFn = @convention(c) (AnyObject, Selector) -> Int32
|
||||
let implementation = method_getImplementation(method)
|
||||
let pid = unsafeBitCast(implementation, to: WebProcessIdentifierFn.self)(webView, selector)
|
||||
return pid > 0 ? Int(pid) : nil
|
||||
}
|
||||
}
|
||||
+87
-157
@@ -1,9 +1,9 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import WebKit
|
||||
import ObjectiveC.runtime
|
||||
|
||||
struct CmuxTopResourceSummary: Sendable {
|
||||
private nonisolated let cmuxTopPIDPathBufferSize = 4096
|
||||
|
||||
nonisolated struct CmuxTopResourceSummary: Sendable {
|
||||
var cpuPercent: Double = 0
|
||||
var residentBytes: Int64 = 0
|
||||
var virtualBytes: Int64 = 0
|
||||
@@ -23,7 +23,7 @@ struct CmuxTopResourceSummary: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct CmuxTopProcessInfo: Sendable {
|
||||
nonisolated struct CmuxTopProcessInfo: Sendable {
|
||||
let pid: Int
|
||||
let parentPID: Int
|
||||
let name: String
|
||||
@@ -33,21 +33,18 @@ struct CmuxTopProcessInfo: Sendable {
|
||||
let cmuxSurfaceID: UUID?
|
||||
let processGroupID: Int?
|
||||
let terminalProcessGroupID: Int?
|
||||
let cpuPercent: Double
|
||||
var cpuPercent: Double
|
||||
let residentBytes: Int64
|
||||
let virtualBytes: Int64
|
||||
let threadCount: Int
|
||||
}
|
||||
|
||||
struct CmuxTopProcessScope: Sendable {
|
||||
nonisolated struct CmuxTopProcessScope: Sendable {
|
||||
let workspaceID: UUID?
|
||||
let surfaceID: UUID?
|
||||
}
|
||||
|
||||
final class CmuxTopProcessSnapshot: @unchecked Sendable {
|
||||
private static let cpuScale = 2048.0
|
||||
private static let pidPathBufferSize = 4096
|
||||
|
||||
nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable {
|
||||
let sampledAt: Date
|
||||
private let includesProcessDetails: Bool
|
||||
private let processesByPID: [Int: CmuxTopProcessInfo]
|
||||
@@ -95,7 +92,7 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
|
||||
[
|
||||
"sampled_at": ISO8601DateFormatter().string(from: sampledAt),
|
||||
"source": "sysctl+proc_pidinfo",
|
||||
"cpu_source": "kinfo_proc.p_pctcpu",
|
||||
"cpu_source": "proc_pidinfo.PROC_PIDTASKINFO.pti_total_user+pti_total_system",
|
||||
"memory_source": "proc_pidinfo.PROC_PIDTASKINFO",
|
||||
"process_details": includesProcessDetails
|
||||
]
|
||||
@@ -112,6 +109,12 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
|
||||
Set(pidsByCMUXSurfaceID[surfaceID] ?? [])
|
||||
}
|
||||
|
||||
func cmuxScopedProcesses() -> [CmuxTopProcessInfo] {
|
||||
processesByPID.values
|
||||
.filter { $0.cmuxWorkspaceID != nil && $0.cmuxSurfaceID != nil }
|
||||
.sorted { $0.pid < $1.pid }
|
||||
}
|
||||
|
||||
func expandedPIDs(rootPIDs: Set<Int>) -> Set<Int> {
|
||||
var result: Set<Int> = []
|
||||
var stack = Array(rootPIDs.filter { $0 > 0 })
|
||||
@@ -269,11 +272,33 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
|
||||
let count = min(processes.count, length / stride)
|
||||
let sampledProcesses = Array(processes.prefix(count))
|
||||
let activeScopeKeys = Set(sampledProcesses.map { scopeCacheKey(from: $0) })
|
||||
let processInfos = sampledProcesses.compactMap {
|
||||
processInfo(from: $0, includeProcessDetails: includeProcessDetails)
|
||||
let sampledAtNanoseconds = cpuSampleClockNanoseconds()
|
||||
var currentCPUSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:]
|
||||
var processRecords: [(info: CmuxTopProcessInfo, cpuSampleKey: CmuxTopProcessScopeCacheKey?)] = []
|
||||
processRecords.reserveCapacity(sampledProcesses.count)
|
||||
for process in sampledProcesses {
|
||||
guard let processRecord = processInfo(
|
||||
from: process,
|
||||
includeProcessDetails: includeProcessDetails,
|
||||
sampledAtNanoseconds: sampledAtNanoseconds,
|
||||
currentCPUSamples: ¤tCPUSamples
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
processRecords.append(processRecord)
|
||||
}
|
||||
let cpuPercentages = cpuPercentages(
|
||||
for: currentCPUSamples,
|
||||
activeKeys: activeScopeKeys,
|
||||
sampledAtNanoseconds: sampledAtNanoseconds
|
||||
)
|
||||
for index in processRecords.indices {
|
||||
guard let key = processRecords[index].cpuSampleKey,
|
||||
let cpuPercent = cpuPercentages[key] else { continue }
|
||||
processRecords[index].info.cpuPercent = cpuPercent
|
||||
}
|
||||
pruneCMUXScopeCache(activeKeys: activeScopeKeys)
|
||||
return processInfos
|
||||
return processRecords.map(\.info)
|
||||
}
|
||||
|
||||
guard errno == ENOMEM else {
|
||||
@@ -285,24 +310,35 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
|
||||
|
||||
private static func processInfo(
|
||||
from kinfo: kinfo_proc,
|
||||
includeProcessDetails: Bool
|
||||
) -> CmuxTopProcessInfo? {
|
||||
includeProcessDetails: Bool,
|
||||
sampledAtNanoseconds: UInt64,
|
||||
currentCPUSamples: inout [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample]
|
||||
) -> (info: CmuxTopProcessInfo, cpuSampleKey: CmuxTopProcessScopeCacheKey?)? {
|
||||
let pid = Int(kinfo.kp_proc.p_pid)
|
||||
guard pid > 0 else { return nil }
|
||||
|
||||
let taskInfo = taskInfo(for: pid)
|
||||
let cacheKey = scopeCacheKey(from: kinfo)
|
||||
let fallbackName = fixedString(kinfo.kp_proc.p_comm)
|
||||
let name = includeProcessDetails ? processName(pid: pid, fallback: fallbackName) : fallbackName
|
||||
let path = includeProcessDetails ? processPath(pid: pid) : nil
|
||||
let rawTTY = Int64(kinfo.kp_eproc.e_tdev)
|
||||
let ttyDevice = rawTTY > 0 ? rawTTY : nil
|
||||
let cmuxScope = cachedCMUXScope(for: pid, cacheKey: scopeCacheKey(from: kinfo))
|
||||
let cmuxScope = cachedCMUXScope(for: pid, cacheKey: cacheKey)
|
||||
let rawProcessGroupID = Int(kinfo.kp_eproc.e_pgid)
|
||||
let processGroupID = rawProcessGroupID > 0 ? rawProcessGroupID : nil
|
||||
let rawTerminalProcessGroupID = Int(kinfo.kp_eproc.e_tpgid)
|
||||
let terminalProcessGroupID = rawTerminalProcessGroupID > 0 ? rawTerminalProcessGroupID : nil
|
||||
let cpuSampleKey: CmuxTopProcessScopeCacheKey?
|
||||
if let taskInfo {
|
||||
let currentCPUSample = cpuSample(from: taskInfo, sampledAtNanoseconds: sampledAtNanoseconds)
|
||||
currentCPUSamples[cacheKey] = currentCPUSample
|
||||
cpuSampleKey = cacheKey
|
||||
} else {
|
||||
cpuSampleKey = nil
|
||||
}
|
||||
|
||||
return CmuxTopProcessInfo(
|
||||
return (CmuxTopProcessInfo(
|
||||
pid: pid,
|
||||
parentPID: Int(kinfo.kp_eproc.e_ppid),
|
||||
name: name.isEmpty ? "pid-\(pid)" : name,
|
||||
@@ -312,134 +348,11 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
|
||||
cmuxSurfaceID: cmuxScope?.surfaceID,
|
||||
processGroupID: processGroupID,
|
||||
terminalProcessGroupID: terminalProcessGroupID,
|
||||
cpuPercent: max(0, Double(kinfo.kp_proc.p_pctcpu) / cpuScale * 100.0),
|
||||
cpuPercent: 0,
|
||||
residentBytes: int64Clamped(taskInfo?.pti_resident_size ?? 0),
|
||||
virtualBytes: int64Clamped(taskInfo?.pti_virtual_size ?? 0),
|
||||
threadCount: Int(taskInfo?.pti_threadnum ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
static func cmuxScope(for pid: Int) -> CmuxTopProcessScope? {
|
||||
guard pid > 0, pid <= Int(Int32.max) else { return nil }
|
||||
|
||||
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)]
|
||||
var size: size_t = 0
|
||||
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0,
|
||||
size > MemoryLayout<Int32>.size else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var buffer = [UInt8](repeating: 0, count: size)
|
||||
let success = buffer.withUnsafeMutableBytes { rawBuffer in
|
||||
sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0
|
||||
}
|
||||
guard success else { return nil }
|
||||
|
||||
return cmuxScope(fromKernProcArgs: Array(buffer.prefix(Int(size))))
|
||||
}
|
||||
|
||||
static func cmuxScope(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessScope? {
|
||||
guard bytes.count > MemoryLayout<Int32>.size else { return nil }
|
||||
|
||||
var argcRaw: Int32 = 0
|
||||
withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in
|
||||
rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout<Int32>.size))
|
||||
}
|
||||
let argc = Int(Int32(littleEndian: argcRaw))
|
||||
guard argc > 0 else { return nil }
|
||||
|
||||
var index = MemoryLayout<Int32>.size
|
||||
skipString(in: bytes, index: &index)
|
||||
skipNulls(in: bytes, index: &index)
|
||||
|
||||
for _ in 0..<argc {
|
||||
guard index < bytes.count else { return nil }
|
||||
skipString(in: bytes, index: &index)
|
||||
skipNulls(in: bytes, index: &index)
|
||||
}
|
||||
|
||||
var workspaceID: UUID?
|
||||
var surfaceID: UUID?
|
||||
while index < bytes.count {
|
||||
skipNulls(in: bytes, index: &index)
|
||||
guard index < bytes.count else { break }
|
||||
|
||||
let start = index
|
||||
skipString(in: bytes, index: &index)
|
||||
guard start < index,
|
||||
let entry = String(bytes: bytes[start..<index], encoding: .utf8) else {
|
||||
continue
|
||||
}
|
||||
|
||||
if let value = value(inEnvironmentEntry: entry, forKey: "CMUX_WORKSPACE_ID") {
|
||||
workspaceID = UUID(uuidString: value) ?? workspaceID
|
||||
} else if workspaceID == nil,
|
||||
let value = value(inEnvironmentEntry: entry, forKey: "CMUX_TAB_ID") {
|
||||
workspaceID = UUID(uuidString: value)
|
||||
} else if let value = value(inEnvironmentEntry: entry, forKey: "CMUX_SURFACE_ID") {
|
||||
surfaceID = UUID(uuidString: value) ?? surfaceID
|
||||
} else if surfaceID == nil,
|
||||
let value = value(inEnvironmentEntry: entry, forKey: "CMUX_PANEL_ID") {
|
||||
surfaceID = UUID(uuidString: value)
|
||||
}
|
||||
|
||||
if workspaceID != nil, surfaceID != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard workspaceID != nil || surfaceID != nil else { return nil }
|
||||
return CmuxTopProcessScope(workspaceID: workspaceID, surfaceID: surfaceID)
|
||||
}
|
||||
|
||||
private static func value(inEnvironmentEntry entry: String, forKey key: String) -> String? {
|
||||
let prefix = "\(key)="
|
||||
guard entry.hasPrefix(prefix) else { return nil }
|
||||
let value = String(entry.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
private static func skipString(in bytes: [UInt8], index: inout Int) {
|
||||
while index < bytes.count, bytes[index] != 0 {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func skipNulls(in bytes: [UInt8], index: inout Int) {
|
||||
while index < bytes.count, bytes[index] == 0 {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func taskInfo(for pid: Int) -> proc_taskinfo? {
|
||||
var info = proc_taskinfo()
|
||||
let expectedSize = MemoryLayout<proc_taskinfo>.stride
|
||||
let size = proc_pidinfo(pid_t(pid), PROC_PIDTASKINFO, 0, &info, Int32(expectedSize))
|
||||
return size == expectedSize ? info : nil
|
||||
}
|
||||
|
||||
private static func processName(pid: Int, fallback: String) -> String {
|
||||
var buffer = [CChar](repeating: 0, count: Int(MAXCOMLEN + 1))
|
||||
let length = proc_name(pid_t(pid), &buffer, UInt32(buffer.count))
|
||||
guard length > 0 else { return fallback }
|
||||
let name = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? fallback : name
|
||||
}
|
||||
|
||||
private static func processPath(pid: Int) -> String? {
|
||||
var buffer = [CChar](repeating: 0, count: pidPathBufferSize)
|
||||
let length = proc_pidpath(pid_t(pid), &buffer, UInt32(buffer.count))
|
||||
guard length > 0 else { return nil }
|
||||
let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return path.isEmpty ? nil : path
|
||||
}
|
||||
|
||||
private static func fixedString<T>(_ value: T) -> String {
|
||||
withUnsafeBytes(of: value) { rawBuffer in
|
||||
let chars = rawBuffer.bindMemory(to: CChar.self)
|
||||
guard let baseAddress = chars.baseAddress else { return "" }
|
||||
return String(cString: baseAddress).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
), cpuSampleKey)
|
||||
}
|
||||
|
||||
private static func deviceIdentifier(forTTYName ttyName: String) -> Int64? {
|
||||
@@ -462,28 +375,45 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable {
|
||||
return Int64(statInfo.st_rdev)
|
||||
}
|
||||
|
||||
private static func int64Clamped(_ value: UInt64) -> Int64 {
|
||||
value > UInt64(Int64.max) ? Int64.max : Int64(value)
|
||||
}
|
||||
|
||||
private static func clampedAdd(_ lhs: Int64, _ rhs: Int64) -> Int64 {
|
||||
if rhs > 0, lhs > Int64.max - rhs {
|
||||
return Int64.max
|
||||
}
|
||||
return lhs + rhs
|
||||
}
|
||||
}
|
||||
|
||||
enum CmuxWebContentProcessIdentifier {
|
||||
static func pid(for webView: WKWebView) -> Int? {
|
||||
let selector = NSSelectorFromString("_webProcessIdentifier")
|
||||
guard let method = class_getInstanceMethod(WKWebView.self, selector) else {
|
||||
return nil
|
||||
private static func taskInfo(for pid: Int) -> proc_taskinfo? {
|
||||
var info = proc_taskinfo()
|
||||
let expectedSize = MemoryLayout<proc_taskinfo>.stride
|
||||
let size = proc_pidinfo(pid_t(pid), PROC_PIDTASKINFO, 0, &info, Int32(expectedSize))
|
||||
return size == expectedSize ? info : nil
|
||||
}
|
||||
|
||||
private static func processName(pid: Int, fallback: String) -> String {
|
||||
var buffer = [CChar](repeating: 0, count: Int(MAXCOMLEN + 1))
|
||||
let length = proc_name(pid_t(pid), &buffer, UInt32(buffer.count))
|
||||
guard length > 0 else { return fallback }
|
||||
let name = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? fallback : name
|
||||
}
|
||||
|
||||
private static func processPath(pid: Int) -> String? {
|
||||
var buffer = [CChar](repeating: 0, count: cmuxTopPIDPathBufferSize)
|
||||
let length = proc_pidpath(pid_t(pid), &buffer, UInt32(buffer.count))
|
||||
guard length > 0 else { return nil }
|
||||
let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return path.isEmpty ? nil : path
|
||||
}
|
||||
|
||||
private static func fixedString<T>(_ value: T) -> String {
|
||||
withUnsafeBytes(of: value) { rawBuffer in
|
||||
let endIndex = rawBuffer.firstIndex(of: 0) ?? rawBuffer.endIndex
|
||||
return String(decoding: rawBuffer[..<endIndex], as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
typealias WebProcessIdentifierFn = @convention(c) (AnyObject, Selector) -> Int32
|
||||
let implementation = method_getImplementation(method)
|
||||
let pid = unsafeBitCast(implementation, to: WebProcessIdentifierFn.self)(webView, selector)
|
||||
return pid > 0 ? Int(pid) : nil
|
||||
private static func int64Clamped(_ value: UInt64) -> Int64 {
|
||||
value > UInt64(Int64.max) ? Int64.max : Int64(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import os
|
||||
|
||||
struct CmuxTopProcessScopeCacheKey: Hashable {
|
||||
nonisolated struct CmuxTopProcessScopeCacheKey: Hashable {
|
||||
let pid: Int
|
||||
let startSeconds: Int
|
||||
let startMicroseconds: Int
|
||||
}
|
||||
|
||||
private struct CmuxTopProcessScopeCacheValue {
|
||||
private nonisolated struct CmuxTopProcessScopeCacheValue {
|
||||
let scope: CmuxTopProcessScope
|
||||
}
|
||||
|
||||
@@ -15,10 +16,11 @@ private struct CmuxTopProcessScopeCacheValue {
|
||||
// both async task-manager sampling and sync v2 system.top socket handling. Keep
|
||||
// this tiny lock isolated to dictionary reads/writes; procargs/sysctl work must
|
||||
// happen outside the critical section.
|
||||
private let cmuxTopScopeCacheLock = NSLock()
|
||||
private var cmuxTopScopeCache: [CmuxTopProcessScopeCacheKey: CmuxTopProcessScopeCacheValue] = [:]
|
||||
private nonisolated let cmuxTopScopeCache = OSAllocatedUnfairLock(
|
||||
initialState: [CmuxTopProcessScopeCacheKey: CmuxTopProcessScopeCacheValue]()
|
||||
)
|
||||
|
||||
extension CmuxTopProcessSnapshot {
|
||||
nonisolated extension CmuxTopProcessSnapshot {
|
||||
static func scopeCacheKey(from kinfo: kinfo_proc) -> CmuxTopProcessScopeCacheKey {
|
||||
let startTime = kinfo.kp_proc.p_un.__p_starttime
|
||||
return CmuxTopProcessScopeCacheKey(
|
||||
@@ -32,27 +34,141 @@ extension CmuxTopProcessSnapshot {
|
||||
for pid: Int,
|
||||
cacheKey: CmuxTopProcessScopeCacheKey
|
||||
) -> CmuxTopProcessScope? {
|
||||
cmuxTopScopeCacheLock.lock()
|
||||
if let cached = cmuxTopScopeCache[cacheKey] {
|
||||
cmuxTopScopeCacheLock.unlock()
|
||||
if let cached = cmuxTopScopeCache.withLock({ cache in cache[cacheKey] }) {
|
||||
return cached.scope
|
||||
}
|
||||
cmuxTopScopeCacheLock.unlock()
|
||||
|
||||
guard let scope = cmuxScope(for: pid) else {
|
||||
guard let scope = cmuxScope(for: pid, expectedCacheKey: cacheKey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmuxTopScopeCacheLock.lock()
|
||||
cmuxTopScopeCache[cacheKey] = CmuxTopProcessScopeCacheValue(scope: scope)
|
||||
cmuxTopScopeCacheLock.unlock()
|
||||
cmuxTopScopeCache.withLock { cache in
|
||||
cache[cacheKey] = CmuxTopProcessScopeCacheValue(scope: scope)
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
static func pruneCMUXScopeCache(activeKeys: Set<CmuxTopProcessScopeCacheKey>) {
|
||||
cmuxTopScopeCacheLock.lock()
|
||||
cmuxTopScopeCache = cmuxTopScopeCache.filter { activeKeys.contains($0.key) }
|
||||
cmuxTopScopeCacheLock.unlock()
|
||||
cmuxTopScopeCache.withLock { cache in
|
||||
cache = cache.filter { activeKeys.contains($0.key) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func cmuxScope(
|
||||
for pid: Int,
|
||||
expectedCacheKey: CmuxTopProcessScopeCacheKey
|
||||
) -> CmuxTopProcessScope? {
|
||||
guard let currentProcess = kinfoProc(for: pid),
|
||||
scopeCacheKey(from: currentProcess) == expectedCacheKey else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)]
|
||||
var size: size_t = 0
|
||||
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0,
|
||||
size > MemoryLayout<Int32>.size else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var buffer = [UInt8](repeating: 0, count: size)
|
||||
let success = buffer.withUnsafeMutableBytes { rawBuffer in
|
||||
sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0
|
||||
}
|
||||
guard success else { return nil }
|
||||
guard let currentProcess = kinfoProc(for: pid),
|
||||
scopeCacheKey(from: currentProcess) == expectedCacheKey else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cmuxScope(fromKernProcArgs: Array(buffer.prefix(Int(size))))
|
||||
}
|
||||
|
||||
static func cmuxScope(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessScope? {
|
||||
guard bytes.count > MemoryLayout<Int32>.size else { return nil }
|
||||
|
||||
var argcRaw: Int32 = 0
|
||||
withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in
|
||||
rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout<Int32>.size))
|
||||
}
|
||||
let argc = Int(Int32(littleEndian: argcRaw))
|
||||
guard argc > 0 else { return nil }
|
||||
|
||||
var index = MemoryLayout<Int32>.size
|
||||
skipString(in: bytes, index: &index)
|
||||
skipNulls(in: bytes, index: &index)
|
||||
|
||||
for _ in 0..<argc {
|
||||
guard index < bytes.count else { return nil }
|
||||
skipString(in: bytes, index: &index)
|
||||
skipNulls(in: bytes, index: &index)
|
||||
}
|
||||
|
||||
var workspaceID: UUID?
|
||||
var surfaceID: UUID?
|
||||
while index < bytes.count {
|
||||
skipNulls(in: bytes, index: &index)
|
||||
guard index < bytes.count else { break }
|
||||
|
||||
let start = index
|
||||
skipString(in: bytes, index: &index)
|
||||
guard start < index,
|
||||
let entry = String(bytes: bytes[start..<index], encoding: .utf8) else {
|
||||
continue
|
||||
}
|
||||
|
||||
if let value = value(inEnvironmentEntry: entry, forKey: "CMUX_WORKSPACE_ID") {
|
||||
workspaceID = UUID(uuidString: value) ?? workspaceID
|
||||
} else if workspaceID == nil,
|
||||
let value = value(inEnvironmentEntry: entry, forKey: "CMUX_TAB_ID") {
|
||||
workspaceID = UUID(uuidString: value)
|
||||
} else if let value = value(inEnvironmentEntry: entry, forKey: "CMUX_SURFACE_ID") {
|
||||
surfaceID = UUID(uuidString: value) ?? surfaceID
|
||||
} else if surfaceID == nil,
|
||||
let value = value(inEnvironmentEntry: entry, forKey: "CMUX_PANEL_ID") {
|
||||
surfaceID = UUID(uuidString: value)
|
||||
}
|
||||
|
||||
if workspaceID != nil, surfaceID != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard workspaceID != nil || surfaceID != nil else { return nil }
|
||||
return CmuxTopProcessScope(workspaceID: workspaceID, surfaceID: surfaceID)
|
||||
}
|
||||
|
||||
private static func value(inEnvironmentEntry entry: String, forKey key: String) -> String? {
|
||||
let prefix = "\(key)="
|
||||
guard entry.hasPrefix(prefix) else { return nil }
|
||||
let value = String(entry.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
private static func skipString(in bytes: [UInt8], index: inout Int) {
|
||||
while index < bytes.count, bytes[index] != 0 {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func skipNulls(in bytes: [UInt8], index: inout Int) {
|
||||
while index < bytes.count, bytes[index] == 0 {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func kinfoProc(for pid: Int) -> kinfo_proc? {
|
||||
guard pid > 0, pid <= Int(Int32.max) else { return nil }
|
||||
|
||||
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, Int32(pid)]
|
||||
var process = kinfo_proc()
|
||||
var length = MemoryLayout<kinfo_proc>.stride
|
||||
let result = sysctl(&mib, u_int(mib.count), &process, &length, nil, 0)
|
||||
guard result == 0,
|
||||
length >= MemoryLayout<kinfo_proc>.stride,
|
||||
process.kp_proc.p_pid == pid_t(pid) else {
|
||||
return nil
|
||||
}
|
||||
return process
|
||||
}
|
||||
}
|
||||
|
||||
+288
-1212
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,389 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct DetachedFolderDragIcon: NSViewRepresentable {
|
||||
let directory: String
|
||||
|
||||
func makeNSView(context: Context) -> DetachedFolderDragIconHostView {
|
||||
DetachedFolderDragIconHostView(directory: directory)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: DetachedFolderDragIconHostView, context: Context) {
|
||||
nsView.directory = directory
|
||||
nsView.syncDetachedIcon()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DetachedFolderDragIconHostView: NSView {
|
||||
var directory: String
|
||||
private var childWindow: NSPanel?
|
||||
private var iconView: DraggableFolderNSView?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private weak var observedParentWindow: NSWindow?
|
||||
|
||||
init(directory: String) {
|
||||
self.directory = directory
|
||||
super.init(frame: NSRect(origin: .zero, size: NSSize(width: 16, height: 16)))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
MainActor.assumeIsolated {
|
||||
tearDownDetachedIcon()
|
||||
}
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
NSSize(width: 16, height: 16)
|
||||
}
|
||||
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
syncDetachedIcon()
|
||||
}
|
||||
|
||||
override func viewDidMoveToSuperview() {
|
||||
super.viewDidMoveToSuperview()
|
||||
syncDetachedIcon()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
syncDetachedIconFrame()
|
||||
}
|
||||
|
||||
func syncDetachedIcon() {
|
||||
guard let parentWindow = window else {
|
||||
tearDownDetachedIcon()
|
||||
return
|
||||
}
|
||||
|
||||
let child = childWindow ?? makeDetachedIconWindow(parentWindow: parentWindow)
|
||||
if child.parent !== parentWindow {
|
||||
child.parent?.removeChildWindow(child)
|
||||
parentWindow.addChildWindow(child, ordered: .above)
|
||||
installParentWindowObservers(parentWindow)
|
||||
}
|
||||
|
||||
if iconView?.directory != directory {
|
||||
iconView?.directory = directory
|
||||
iconView?.updateIcon()
|
||||
}
|
||||
|
||||
child.orderFront(nil)
|
||||
syncDetachedIconFrame()
|
||||
}
|
||||
|
||||
private func makeDetachedIconWindow(parentWindow: NSWindow) -> NSPanel {
|
||||
let iconView = DraggableFolderNSView(directory: directory)
|
||||
iconView.frame = NSRect(origin: .zero, size: NSSize(width: 16, height: 16))
|
||||
|
||||
let panel = NSPanel(
|
||||
contentRect: iconView.frame,
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
panel.contentView = iconView
|
||||
panel.backgroundColor = .clear
|
||||
panel.isOpaque = false
|
||||
panel.hasShadow = false
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.ignoresMouseEvents = false
|
||||
panel.isMovable = false
|
||||
panel.isMovableByWindowBackground = false
|
||||
panel.collectionBehavior = [.fullScreenAuxiliary]
|
||||
panel.identifier = NSUserInterfaceItemIdentifier("cmux.folderDragIcon")
|
||||
parentWindow.addChildWindow(panel, ordered: .above)
|
||||
self.childWindow = panel
|
||||
self.iconView = iconView
|
||||
installParentWindowObservers(parentWindow)
|
||||
return panel
|
||||
}
|
||||
|
||||
private func installParentWindowObservers(_ parentWindow: NSWindow) {
|
||||
guard observedParentWindow !== parentWindow || observers.isEmpty else { return }
|
||||
removeParentWindowObservers()
|
||||
|
||||
// The child panel frame is derived from this host view in the parent
|
||||
// window. AppKit does not call layout() when only the parent window
|
||||
// moves, resizes, or changes miniaturized state, so observe those
|
||||
// window events and recompute the panel's screen-space frame.
|
||||
let center = NotificationCenter.default
|
||||
let names: [Notification.Name] = [
|
||||
NSWindow.didMoveNotification,
|
||||
NSWindow.didResizeNotification,
|
||||
NSWindow.didMiniaturizeNotification,
|
||||
NSWindow.didDeminiaturizeNotification,
|
||||
]
|
||||
observers = names.map { name in
|
||||
center.addObserver(forName: name, object: parentWindow, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.syncDetachedIconFrame()
|
||||
}
|
||||
}
|
||||
}
|
||||
observedParentWindow = parentWindow
|
||||
}
|
||||
|
||||
private func syncDetachedIconFrame() {
|
||||
guard let parentWindow = window,
|
||||
let childWindow else { return }
|
||||
let localRect = bounds.isEmpty
|
||||
? NSRect(origin: .zero, size: NSSize(width: 16, height: 16))
|
||||
: bounds
|
||||
let rectInWindow = convert(localRect, to: nil)
|
||||
let rectOnScreen = parentWindow.convertToScreen(rectInWindow)
|
||||
if childWindow.frame.origin != rectOnScreen.origin || childWindow.frame.size != rectOnScreen.size {
|
||||
childWindow.setFrame(rectOnScreen, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeParentWindowObservers() {
|
||||
for observer in observers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
observers.removeAll()
|
||||
observedParentWindow = nil
|
||||
}
|
||||
|
||||
private func tearDownDetachedIcon() {
|
||||
// Observer lifetime is tied to the derived child panel: once this host
|
||||
// leaves its parent window or deinitializes, remove both so no stale
|
||||
// panel keeps tracking an old parent window.
|
||||
removeParentWindowObservers()
|
||||
if let childWindow {
|
||||
childWindow.parent?.removeChildWindow(childWindow)
|
||||
childWindow.orderOut(nil)
|
||||
}
|
||||
childWindow = nil
|
||||
iconView = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DraggableFolderNSView: NSView, NSDraggingSource {
|
||||
private final class FolderIconImageView: NSImageView {
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
}
|
||||
|
||||
var directory: String
|
||||
private var imageView: FolderIconImageView!
|
||||
private var pendingDragEvent: NSEvent?
|
||||
private var pendingDragStartPoint: NSPoint?
|
||||
private let dragStartThresholdSquared: CGFloat = 9
|
||||
|
||||
private func formatPoint(_ point: NSPoint) -> String {
|
||||
String(format: "(%.1f,%.1f)", point.x, point.y)
|
||||
}
|
||||
|
||||
init(directory: String) {
|
||||
self.directory = directory
|
||||
super.init(frame: .zero)
|
||||
setupImageView()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
NSSize(width: 16, height: 16)
|
||||
}
|
||||
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
|
||||
private func setupImageView() {
|
||||
imageView = FolderIconImageView()
|
||||
imageView.imageScaling = .scaleProportionallyDown
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
imageView.topAnchor.constraint(equalTo: topAnchor),
|
||||
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
imageView.widthAnchor.constraint(equalToConstant: 16),
|
||||
imageView.heightAnchor.constraint(equalToConstant: 16),
|
||||
])
|
||||
let dragHint = String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")
|
||||
toolTip = dragHint
|
||||
imageView.toolTip = dragHint
|
||||
updateIcon()
|
||||
}
|
||||
|
||||
func updateIcon() {
|
||||
#if DEBUG
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
#endif
|
||||
|
||||
let icon = NSWorkspace.shared.icon(forFile: directory)
|
||||
icon.size = NSSize(width: 16, height: 16)
|
||||
imageView.image = icon
|
||||
}
|
||||
|
||||
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
|
||||
return context == .outsideApplication ? [.copy, .link] : .copy
|
||||
}
|
||||
|
||||
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
|
||||
#if DEBUG
|
||||
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
|
||||
let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil"
|
||||
cmuxDebugLog("folder.dragEnd dirBytes=\(directory.utf8.count) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)")
|
||||
#endif
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
guard bounds.contains(point) else { return nil }
|
||||
let hit = super.hitTest(point)
|
||||
#if DEBUG
|
||||
let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
let imageHit = (hit === imageView)
|
||||
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
|
||||
cmuxDebugLog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView nowMovable=\(nowMovable)")
|
||||
#endif
|
||||
return self
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
if event.modifierFlags.contains(.control) {
|
||||
clearPendingDrag()
|
||||
showPathMenu()
|
||||
return
|
||||
}
|
||||
|
||||
if event.clickCount == 2 {
|
||||
clearPendingDrag()
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory)
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let localPoint = convert(event.locationInWindow, from: nil)
|
||||
let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
|
||||
let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil"
|
||||
cmuxDebugLog("folder.mouseDown dirBytes=\(directory.utf8.count) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)")
|
||||
#endif
|
||||
|
||||
pendingDragEvent = event
|
||||
pendingDragStartPoint = convert(event.locationInWindow, from: nil)
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
guard let dragStartPoint = pendingDragStartPoint else { return }
|
||||
|
||||
let currentPoint = convert(event.locationInWindow, from: nil)
|
||||
let deltaX = currentPoint.x - dragStartPoint.x
|
||||
let deltaY = currentPoint.y - dragStartPoint.y
|
||||
guard (deltaX * deltaX) + (deltaY * deltaY) >= dragStartThresholdSquared else { return }
|
||||
|
||||
let dragEvent = pendingDragEvent ?? event
|
||||
clearPendingDrag()
|
||||
beginFolderDrag(with: dragEvent)
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
clearPendingDrag()
|
||||
super.mouseUp(with: event)
|
||||
}
|
||||
|
||||
private func clearPendingDrag() {
|
||||
pendingDragEvent = nil
|
||||
pendingDragStartPoint = nil
|
||||
}
|
||||
|
||||
private func beginFolderDrag(with event: NSEvent) {
|
||||
let fileURL = URL(fileURLWithPath: directory)
|
||||
let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL)
|
||||
|
||||
let iconImage = NSWorkspace.shared.icon(forFile: directory)
|
||||
iconImage.size = NSSize(width: 32, height: 32)
|
||||
draggingItem.setDraggingFrame(bounds, contents: iconImage)
|
||||
|
||||
let session = beginDraggingSession(with: [draggingItem], event: event, source: self)
|
||||
#if DEBUG
|
||||
let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0
|
||||
cmuxDebugLog("folder.dragStart dirBytes=\(directory.utf8.count) pasteboardItems=\(itemCount)")
|
||||
#endif
|
||||
}
|
||||
|
||||
override func rightMouseDown(with event: NSEvent) {
|
||||
clearPendingDrag()
|
||||
showPathMenu()
|
||||
}
|
||||
|
||||
private func showPathMenu() {
|
||||
let menu = buildPathMenu()
|
||||
// Pop up menu at bottom-left of icon (like native proxy icon)
|
||||
let menuLocation = NSPoint(x: 0, y: bounds.height)
|
||||
menu.popUp(positioning: nil, at: menuLocation, in: self)
|
||||
}
|
||||
|
||||
private func buildPathMenu() -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
let url = URL(fileURLWithPath: directory).standardized
|
||||
var pathComponents: [URL] = []
|
||||
|
||||
// Build path from current directory up to root
|
||||
var current = url
|
||||
while current.path != "/" {
|
||||
pathComponents.append(current)
|
||||
current = current.deletingLastPathComponent()
|
||||
}
|
||||
pathComponents.append(URL(fileURLWithPath: "/"))
|
||||
|
||||
// Add path components (current dir at top, root at bottom - matches native macOS)
|
||||
for pathURL in pathComponents {
|
||||
let icon = NSWorkspace.shared.icon(forFile: pathURL.path)
|
||||
icon.size = NSSize(width: 16, height: 16)
|
||||
|
||||
let displayName: String
|
||||
if pathURL.path == "/" {
|
||||
// Use the volume name for root
|
||||
if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName {
|
||||
displayName = volumeName
|
||||
} else {
|
||||
displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD")
|
||||
}
|
||||
} else {
|
||||
displayName = FileManager.default.displayName(atPath: pathURL.path)
|
||||
}
|
||||
|
||||
let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.image = icon
|
||||
item.representedObject = pathURL
|
||||
menu.addItem(item)
|
||||
}
|
||||
|
||||
// Add computer name at the bottom (like native proxy icon)
|
||||
let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage()
|
||||
computerIcon.size = NSSize(width: 16, height: 16)
|
||||
|
||||
let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "")
|
||||
computerItem.target = self
|
||||
computerItem.image = computerIcon
|
||||
menu.addItem(computerItem)
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
@objc private func openPathComponent(_ sender: NSMenuItem) {
|
||||
guard let url = sender.representedObject as? URL else { return }
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path)
|
||||
}
|
||||
|
||||
@objc private func openComputer(_ sender: NSMenuItem) {
|
||||
// Open the root filesystem entry represented by the bottom path item.
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,192 @@
|
||||
import AppKit
|
||||
import Bonsplit
|
||||
import Foundation
|
||||
|
||||
enum FileDropResolvedBehavior: Equatable {
|
||||
case text
|
||||
case preview
|
||||
|
||||
var inverted: FileDropResolvedBehavior {
|
||||
switch self {
|
||||
case .text:
|
||||
return .preview
|
||||
case .preview:
|
||||
return .text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FileDropDefaultBehavior: String, CaseIterable, Identifiable {
|
||||
case text
|
||||
case preview
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var resolvedBehavior: FileDropResolvedBehavior {
|
||||
switch self {
|
||||
case .text:
|
||||
return .text
|
||||
case .preview:
|
||||
return .preview
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .text:
|
||||
return String(localized: "settings.app.fileDrop.defaultBehavior.text", defaultValue: "Drop path text")
|
||||
case .preview:
|
||||
return String(localized: "settings.app.fileDrop.defaultBehavior.preview", defaultValue: "Open file preview")
|
||||
}
|
||||
}
|
||||
|
||||
var settingsSubtitle: String {
|
||||
switch self {
|
||||
case .text:
|
||||
return String(
|
||||
localized: "settings.app.fileDrop.defaultBehavior.text.subtitle",
|
||||
defaultValue: "Over terminals and editors, dragging files inserts shell-escaped paths. Hold Shift to open a file preview or split."
|
||||
)
|
||||
case .preview:
|
||||
return String(
|
||||
localized: "settings.app.fileDrop.defaultBehavior.preview.subtitle",
|
||||
defaultValue: "Dragging files opens previews or split panes. Hold Shift over terminals and editors to insert path text."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FileDropTextDestinationKind: Equatable {
|
||||
case terminal
|
||||
case editor
|
||||
|
||||
func hintText(for alternateBehavior: FileDropResolvedBehavior) -> String? {
|
||||
switch alternateBehavior {
|
||||
case .text:
|
||||
switch self {
|
||||
case .terminal:
|
||||
return String(
|
||||
localized: "fileDrop.holdShiftDropIntoTerminal",
|
||||
defaultValue: "Hold Shift to drop into terminal"
|
||||
)
|
||||
case .editor:
|
||||
return String(
|
||||
localized: "fileDrop.holdShiftDropIntoEditor",
|
||||
defaultValue: "Hold Shift to drop into editor"
|
||||
)
|
||||
}
|
||||
case .preview:
|
||||
return String(
|
||||
localized: "fileDrop.holdShiftOpenAsSplit",
|
||||
defaultValue: "Hold Shift to open as split"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FileDropBehaviorSettings {
|
||||
static let defaultBehaviorKey = "fileDrop.defaultBehavior"
|
||||
static let defaultBehavior: FileDropDefaultBehavior = .text
|
||||
|
||||
static func behavior(for rawValue: String?) -> FileDropDefaultBehavior {
|
||||
FileDropDefaultBehavior(rawValue: rawValue ?? "") ?? defaultBehavior
|
||||
}
|
||||
|
||||
static func behavior(defaults: UserDefaults = .standard) -> FileDropDefaultBehavior {
|
||||
behavior(for: defaults.string(forKey: defaultBehaviorKey))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum FileDropTextDropController {
|
||||
static func panelIdForTerminalDropFocus(
|
||||
terminalSurfaceId: UUID,
|
||||
workspace: Workspace
|
||||
) -> UUID? {
|
||||
if workspace.panels[terminalSurfaceId] != nil {
|
||||
return terminalSurfaceId
|
||||
}
|
||||
return workspace.panelIdFromSurfaceId(TabID(uuid: terminalSurfaceId))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func performPanelTextDrop(
|
||||
workspace: Workspace,
|
||||
panelId: UUID,
|
||||
focusIntent: PanelFocusIntent,
|
||||
window: NSWindow?,
|
||||
insert: () -> Bool
|
||||
) -> Bool {
|
||||
guard insert() else { return false }
|
||||
focusPanelAfterSuccessfulTextDrop(
|
||||
workspace: workspace,
|
||||
panelId: panelId,
|
||||
focusIntent: focusIntent,
|
||||
window: window
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func performTerminalFileDrop(
|
||||
workspace: Workspace,
|
||||
panelId: UUID,
|
||||
hostedView: GhosttySurfaceScrollView,
|
||||
urls: [URL],
|
||||
window: NSWindow?
|
||||
) -> Bool {
|
||||
performPanelTextDrop(
|
||||
workspace: workspace,
|
||||
panelId: panelId,
|
||||
focusIntent: .terminal(.surface),
|
||||
window: window,
|
||||
insert: {
|
||||
hostedView.handleDroppedURLs(urls)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func performTerminalFileDrop(
|
||||
terminal: GhosttyNSView,
|
||||
urls: [URL]
|
||||
) -> Bool {
|
||||
guard let workspaceId = terminal.tabId,
|
||||
let terminalSurfaceId = terminal.terminalSurface?.id,
|
||||
let workspace = AppDelegate.shared?.workspaceFor(tabId: workspaceId),
|
||||
let panelId = panelIdForTerminalDropFocus(
|
||||
terminalSurfaceId: terminalSurfaceId,
|
||||
workspace: workspace
|
||||
) else {
|
||||
return terminal.handleDroppedFileURLs(urls)
|
||||
}
|
||||
return performPanelTextDrop(
|
||||
workspace: workspace,
|
||||
panelId: panelId,
|
||||
focusIntent: .terminal(.surface),
|
||||
window: terminal.window,
|
||||
insert: {
|
||||
terminal.handleDroppedFileURLs(urls)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
static func focusPanelAfterSuccessfulTextDrop(
|
||||
workspace: Workspace,
|
||||
panelId: UUID,
|
||||
focusIntent: PanelFocusIntent,
|
||||
window: NSWindow?
|
||||
) {
|
||||
AppDelegate.shared?.noteMainPanelKeyboardFocusIntent(
|
||||
workspaceId: workspace.id,
|
||||
panelId: panelId,
|
||||
in: window
|
||||
)
|
||||
workspace.focusPanel(panelId, focusIntent: focusIntent)
|
||||
_ = workspace.panels[panelId]?.restoreFocusIntent(focusIntent)
|
||||
}
|
||||
}
|
||||
|
||||
enum DragOverlayRoutingPolicy {
|
||||
static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
|
||||
static let filePreviewTransferType = NSPasteboard.PasteboardType("com.cmux.filepreview.transfer")
|
||||
@@ -25,8 +211,98 @@ enum DragOverlayRoutingPolicy {
|
||||
PasteboardFileURLReader.hasFileURLType(pasteboardTypes ?? [])
|
||||
}
|
||||
|
||||
static func hasFileDropPayload(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool {
|
||||
hasFileURL(pasteboardTypes) || hasFilePreviewTransfer(pasteboardTypes)
|
||||
}
|
||||
|
||||
static func fileURLs(from pasteboard: NSPasteboard) -> [URL] {
|
||||
PasteboardFileURLReader.fileURLs(from: pasteboard)
|
||||
let fileURLs = PasteboardFileURLReader.fileURLs(from: pasteboard)
|
||||
if !fileURLs.isEmpty {
|
||||
return fileURLs
|
||||
}
|
||||
guard let dragId = FilePreviewDragPasteboardWriter.dragID(from: pasteboard),
|
||||
let entry = FilePreviewDragRegistry.shared.entry(id: dragId) else {
|
||||
return []
|
||||
}
|
||||
return [URL(fileURLWithPath: entry.filePath).standardizedFileURL]
|
||||
}
|
||||
|
||||
static func textDropOperation(pasteboardTypes: [NSPasteboard.PasteboardType]?) -> NSDragOperation {
|
||||
hasFilePreviewTransfer(pasteboardTypes) ? .move : .copy
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static var currentModifierFlags: NSEvent.ModifierFlags {
|
||||
mergedModifierFlags(
|
||||
appKitFlags: NSApp.currentEvent?.modifierFlags ?? NSEvent.modifierFlags,
|
||||
cgEventFlags: CGEventSource.flagsState(.combinedSessionState)
|
||||
)
|
||||
}
|
||||
|
||||
static func mergedModifierFlags(
|
||||
appKitFlags: NSEvent.ModifierFlags,
|
||||
cgEventFlags: CGEventFlags
|
||||
) -> NSEvent.ModifierFlags {
|
||||
var flags = appKitFlags
|
||||
if cgEventFlags.contains(.maskShift) {
|
||||
flags.insert(.shift)
|
||||
}
|
||||
if cgEventFlags.contains(.maskCommand) {
|
||||
flags.insert(.command)
|
||||
}
|
||||
if cgEventFlags.contains(.maskAlternate) {
|
||||
flags.insert(.option)
|
||||
}
|
||||
if cgEventFlags.contains(.maskControl) {
|
||||
flags.insert(.control)
|
||||
}
|
||||
if cgEventFlags.contains(.maskAlphaShift) {
|
||||
flags.insert(.capsLock)
|
||||
}
|
||||
if cgEventFlags.contains(.maskSecondaryFn) {
|
||||
flags.insert(.function)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
static func resolvedFileDropBehavior(
|
||||
pasteboardTypes: [NSPasteboard.PasteboardType]?,
|
||||
modifierFlags: NSEvent.ModifierFlags,
|
||||
canDropAsText: Bool = true,
|
||||
defaultBehavior: FileDropDefaultBehavior = FileDropBehaviorSettings.behavior()
|
||||
) -> FileDropResolvedBehavior? {
|
||||
guard hasFileDropPayload(pasteboardTypes) else { return nil }
|
||||
guard canDropAsText else { return .preview }
|
||||
let behavior = defaultBehavior.resolvedBehavior
|
||||
return modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.shift)
|
||||
? behavior.inverted
|
||||
: behavior
|
||||
}
|
||||
|
||||
static func shouldRouteFileDropToTextDestination(
|
||||
pasteboardTypes: [NSPasteboard.PasteboardType]?,
|
||||
modifierFlags: NSEvent.ModifierFlags,
|
||||
canDropAsText: Bool = true,
|
||||
defaultBehavior: FileDropDefaultBehavior = FileDropBehaviorSettings.behavior()
|
||||
) -> Bool {
|
||||
resolvedFileDropBehavior(
|
||||
pasteboardTypes: pasteboardTypes,
|
||||
modifierFlags: modifierFlags,
|
||||
canDropAsText: canDropAsText,
|
||||
defaultBehavior: defaultBehavior
|
||||
) == .text
|
||||
}
|
||||
|
||||
static func alternateFileDropBehaviorForShiftHint(
|
||||
pasteboardTypes: [NSPasteboard.PasteboardType]?,
|
||||
modifierFlags: NSEvent.ModifierFlags,
|
||||
canDropAsText: Bool = true,
|
||||
defaultBehavior: FileDropDefaultBehavior = FileDropBehaviorSettings.behavior()
|
||||
) -> FileDropResolvedBehavior? {
|
||||
guard hasFileDropPayload(pasteboardTypes) else { return nil }
|
||||
guard canDropAsText else { return nil }
|
||||
guard !modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.shift) else { return nil }
|
||||
return defaultBehavior.resolvedBehavior.inverted
|
||||
}
|
||||
|
||||
static func shouldCaptureFileDropDestination(
|
||||
@@ -35,7 +311,7 @@ enum DragOverlayRoutingPolicy {
|
||||
) -> Bool {
|
||||
// The window overlay delegates Finder/sidebar files to pane-level Bonsplit targets.
|
||||
_ = hasLocalDraggingSource
|
||||
guard hasFileURL(pasteboardTypes) else { return false }
|
||||
guard hasFileDropPayload(pasteboardTypes) else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+250
-161
@@ -60,7 +60,7 @@ struct FeedPanelView: View {
|
||||
case .actionable:
|
||||
return String(localized: "feed.filter.actionable", defaultValue: "Actionable")
|
||||
case .activity:
|
||||
return String(localized: "feed.filter.activity", defaultValue: "Activity")
|
||||
return String(localized: "feed.filter.activity", defaultValue: "All Activity")
|
||||
}
|
||||
}
|
||||
var symbolName: String {
|
||||
@@ -108,7 +108,7 @@ struct FeedPanelView: View {
|
||||
|
||||
private var controlBarContent: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach([Filter.actionable]) { f in
|
||||
ForEach(Filter.allCases) { f in
|
||||
FeedSecondaryFilterButton(
|
||||
filter: f,
|
||||
isSelected: filter == f
|
||||
@@ -161,9 +161,12 @@ private struct FeedListView: View {
|
||||
@State private var focusSnapshot = FeedFocusSnapshot()
|
||||
@State private var scrollRequest: FeedScrollRequest?
|
||||
@State private var scrollRequestSequence = 0
|
||||
@State private var stopDrafts: [UUID: FeedStopDraft] = [:]
|
||||
|
||||
var body: some View {
|
||||
let snapshots = visibleSnapshots(items)
|
||||
let activityGroups = filter == .activity ? activitySnapshotGroups(snapshots) : nil
|
||||
let focusSnapshots = activityGroups?.ordered ?? snapshots
|
||||
let rowActions = FeedRowActions.bound()
|
||||
ScrollViewReader { proxy in
|
||||
Group {
|
||||
@@ -172,6 +175,7 @@ private struct FeedListView: View {
|
||||
} else {
|
||||
contentBody(
|
||||
snapshots: snapshots,
|
||||
activityGroups: activityGroups,
|
||||
actions: rowActions
|
||||
)
|
||||
}
|
||||
@@ -190,13 +194,13 @@ private struct FeedListView: View {
|
||||
syncFeedFocusSnapshot(window: window)
|
||||
},
|
||||
onMoveSelection: { delta in
|
||||
moveSelection(in: snapshots, delta: delta)
|
||||
moveSelection(in: focusSnapshots, delta: delta)
|
||||
},
|
||||
onActivateSelection: {
|
||||
activateSelection(in: snapshots, actions: rowActions)
|
||||
activateSelection(in: focusSnapshots, actions: rowActions)
|
||||
},
|
||||
onFocusFirstItemRequested: {
|
||||
focusFirstVisibleItem(in: snapshots, focusHost: false)
|
||||
focusFirstVisibleItem(in: focusSnapshots, focusHost: false)
|
||||
},
|
||||
onFocusChanged: { focused in
|
||||
let window = activeFeedWindow()
|
||||
@@ -217,6 +221,7 @@ private struct FeedListView: View {
|
||||
@ViewBuilder
|
||||
private func contentBody(
|
||||
snapshots: [FeedItemSnapshot],
|
||||
activityGroups: ActivitySnapshotGroups?,
|
||||
actions: FeedRowActions
|
||||
) -> some View {
|
||||
switch filter {
|
||||
@@ -226,29 +231,11 @@ private struct FeedListView: View {
|
||||
actions: actions
|
||||
)
|
||||
case .activity:
|
||||
let stable = snapshots.filter(prefersStableSurface)
|
||||
let history = snapshots.filter { !prefersStableSurface($0) }
|
||||
if history.isEmpty && !hasMorePersistedItems {
|
||||
stableScrollSurface(
|
||||
snapshots: stable,
|
||||
actions: actions
|
||||
)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
if !stable.isEmpty {
|
||||
stableRows(
|
||||
snapshots: stable,
|
||||
actions: actions
|
||||
)
|
||||
rowSeparator
|
||||
}
|
||||
historyList(
|
||||
snapshots: history,
|
||||
actions: actions,
|
||||
showsLoadMore: hasMorePersistedItems
|
||||
)
|
||||
}
|
||||
}
|
||||
activityScrollSurface(
|
||||
groups: activityGroups ?? activitySnapshotGroups(snapshots),
|
||||
actions: actions,
|
||||
showsLoadMore: hasMorePersistedItems
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,36 +259,34 @@ private struct FeedListView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
private func stableRows(
|
||||
snapshots: [FeedItemSnapshot],
|
||||
actions: FeedRowActions
|
||||
) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(snapshots.enumerated()), id: \.element.id) { idx, snapshot in
|
||||
rowSurface(
|
||||
snapshot: snapshot,
|
||||
actions: actions,
|
||||
showsDivider: idx < snapshots.count - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func historyList(
|
||||
snapshots: [FeedItemSnapshot],
|
||||
private func activityScrollSurface(
|
||||
groups: ActivitySnapshotGroups,
|
||||
actions: FeedRowActions,
|
||||
showsLoadMore: Bool
|
||||
) -> some View {
|
||||
List {
|
||||
// Single chronological history stream. The plain List keeps
|
||||
// virtualization for older feed rows while active decision
|
||||
// surfaces live above it in a stable stack.
|
||||
ForEach(Array(snapshots.enumerated()), id: \.element.id) { idx, snapshot in
|
||||
ForEach(Array(groups.stable.enumerated()), id: \.element.id) { idx, snapshot in
|
||||
rowSurface(
|
||||
snapshot: snapshot,
|
||||
actions: actions,
|
||||
showsDivider: idx < snapshots.count - 1
|
||||
showsDivider: idx < groups.stable.count - 1
|
||||
)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
if !groups.stable.isEmpty && (!groups.history.isEmpty || showsLoadMore) {
|
||||
rowSeparator
|
||||
.id("feed.activity.separator")
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
ForEach(Array(groups.history.enumerated()), id: \.element.id) { idx, snapshot in
|
||||
rowSurface(
|
||||
snapshot: snapshot,
|
||||
actions: actions,
|
||||
showsDivider: idx < groups.history.count - 1
|
||||
)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowSeparator(.hidden)
|
||||
@@ -324,6 +309,27 @@ private struct FeedListView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private struct ActivitySnapshotGroups {
|
||||
let stable: [FeedItemSnapshot]
|
||||
let history: [FeedItemSnapshot]
|
||||
let ordered: [FeedItemSnapshot]
|
||||
}
|
||||
|
||||
private func activitySnapshotGroups(_ snapshots: [FeedItemSnapshot]) -> ActivitySnapshotGroups {
|
||||
var stable: [FeedItemSnapshot] = []
|
||||
var history: [FeedItemSnapshot] = []
|
||||
stable.reserveCapacity(snapshots.count)
|
||||
history.reserveCapacity(snapshots.count)
|
||||
for snapshot in snapshots {
|
||||
if prefersStableSurface(snapshot) {
|
||||
stable.append(snapshot)
|
||||
} else {
|
||||
history.append(snapshot)
|
||||
}
|
||||
}
|
||||
return ActivitySnapshotGroups(stable: stable, history: history, ordered: stable + history)
|
||||
}
|
||||
|
||||
private func rowSurface(
|
||||
snapshot: FeedItemSnapshot,
|
||||
actions: FeedRowActions,
|
||||
@@ -335,12 +341,16 @@ private struct FeedListView: View {
|
||||
isSelected: focusSnapshot.selectedItemId == snapshot.id,
|
||||
isFocusActive: focusSnapshot.isKeyboardActive && focusSnapshot.selectedItemId == snapshot.id,
|
||||
showsDivider: showsDivider,
|
||||
stopDraft: stopDraftBinding(for: snapshot.id),
|
||||
onPressSelect: {
|
||||
selectRow(snapshot.id, focusFeed: true)
|
||||
selectRow(snapshot.id, focusFeed: false)
|
||||
},
|
||||
onControlFocus: {
|
||||
selectRow(snapshot.id, focusFeed: false)
|
||||
},
|
||||
onControlAction: {
|
||||
selectRow(snapshot.id, focusFeed: true)
|
||||
},
|
||||
onControlBlur: {
|
||||
syncFeedFocusSnapshot()
|
||||
},
|
||||
@@ -352,6 +362,19 @@ private struct FeedListView: View {
|
||||
.id(snapshot.id)
|
||||
}
|
||||
|
||||
private func stopDraftBinding(for id: UUID) -> Binding<FeedStopDraft> {
|
||||
Binding(
|
||||
get: { stopDrafts[id] ?? FeedStopDraft() },
|
||||
set: { draft in
|
||||
if draft.isPristine {
|
||||
stopDrafts.removeValue(forKey: id)
|
||||
} else {
|
||||
stopDrafts[id] = draft
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Walks the full items list (not just the filtered visible set),
|
||||
/// ordered by createdAt, and records the most recent user-prompt
|
||||
/// text per workstreamId. Rows consult this dict to show a
|
||||
@@ -420,7 +443,7 @@ private struct FeedListView: View {
|
||||
"frBefore=\(feedDebugResponderSummary(window?.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
if focusFeed || selectionChanged {
|
||||
if focusFeed {
|
||||
FeedInlineNativeTextView.blurActiveEditor()
|
||||
}
|
||||
let optimisticSnapshot = FeedFocusSnapshot(selectedItemId: id, isKeyboardActive: true)
|
||||
@@ -574,18 +597,29 @@ private struct FeedScrollRequest: Equatable {
|
||||
let sequence: Int
|
||||
}
|
||||
|
||||
struct FeedStopDraft: Equatable {
|
||||
var reply = ""
|
||||
|
||||
var isPristine: Bool {
|
||||
reply.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private struct FeedRowSurface: View {
|
||||
let snapshot: FeedItemSnapshot
|
||||
let actions: FeedRowActions
|
||||
let isSelected: Bool
|
||||
let isFocusActive: Bool
|
||||
let showsDivider: Bool
|
||||
@Binding var stopDraft: FeedStopDraft
|
||||
let onPressSelect: () -> Void
|
||||
let onControlFocus: () -> Void
|
||||
let onControlAction: () -> Void
|
||||
let onControlBlur: () -> Void
|
||||
let onActivate: () -> Void
|
||||
|
||||
@State private var isHovered = false
|
||||
@State private var stopReplyFocusRequest = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -595,8 +629,13 @@ private struct FeedRowSurface: View {
|
||||
isSelected: isFocusActive,
|
||||
onPressSelect: onPressSelect,
|
||||
onControlFocus: onControlFocus,
|
||||
onControlAction: onControlAction,
|
||||
onControlBlur: onControlBlur,
|
||||
onActivate: onActivate
|
||||
onActivate: onActivate,
|
||||
stopDraft: $stopDraft,
|
||||
stopDraftValue: stopDraft,
|
||||
stopFocusRequest: $stopReplyFocusRequest,
|
||||
stopFocusRequestValue: stopReplyFocusRequest
|
||||
)
|
||||
.equatable()
|
||||
if showsDivider {
|
||||
@@ -950,14 +989,21 @@ struct FeedItemRow: View, Equatable {
|
||||
let isSelected: Bool
|
||||
let onPressSelect: () -> Void
|
||||
let onControlFocus: () -> Void
|
||||
let onControlAction: () -> Void
|
||||
let onControlBlur: () -> Void
|
||||
let onActivate: () -> Void
|
||||
@Binding var stopDraft: FeedStopDraft
|
||||
let stopDraftValue: FeedStopDraft
|
||||
@Binding var stopFocusRequest: Int
|
||||
let stopFocusRequestValue: Int
|
||||
|
||||
@State private var didHandlePressSelection = false
|
||||
|
||||
static func == (lhs: FeedItemRow, rhs: FeedItemRow) -> Bool {
|
||||
lhs.snapshot == rhs.snapshot
|
||||
&& lhs.isSelected == rhs.isSelected
|
||||
&& lhs.stopDraftValue == rhs.stopDraftValue
|
||||
&& lhs.stopFocusRequestValue == rhs.stopFocusRequestValue
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -1118,11 +1164,11 @@ struct FeedItemRow: View, Equatable {
|
||||
case .claude: return Color(red: 0.92, green: 0.54, blue: 0.29)
|
||||
case .codex: return .green
|
||||
case .opencode: return .blue
|
||||
case .hermesAgent: return .teal
|
||||
case .cursor: return .purple
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var sourceChipBackground: Color {
|
||||
return sourceChipForeground.opacity(0.18)
|
||||
}
|
||||
@@ -1180,6 +1226,7 @@ struct FeedItemRow: View, Equatable {
|
||||
toolInputJSON: toolInputJSON,
|
||||
source: snapshot.source,
|
||||
status: snapshot.status,
|
||||
onActionRow: onControlAction,
|
||||
onApprove: { mode in
|
||||
actions.approvePermission(snapshot.id, mode)
|
||||
}
|
||||
@@ -1191,6 +1238,7 @@ struct FeedItemRow: View, Equatable {
|
||||
status: snapshot.status,
|
||||
isRowSelected: isSelected,
|
||||
onFocusRow: onControlFocus,
|
||||
onActionRow: onControlAction,
|
||||
onBlurRow: onControlBlur,
|
||||
onApprove: { mode, feedback in
|
||||
actions.approveExitPlan(snapshot.id, mode, feedback)
|
||||
@@ -1203,6 +1251,7 @@ struct FeedItemRow: View, Equatable {
|
||||
status: snapshot.status,
|
||||
isRowSelected: isSelected,
|
||||
onFocusRow: onControlFocus,
|
||||
onActionRow: onControlAction,
|
||||
onBlurRow: onControlBlur,
|
||||
context: displayContext,
|
||||
onReply: { selections in
|
||||
@@ -1211,9 +1260,10 @@ struct FeedItemRow: View, Equatable {
|
||||
)
|
||||
case .stop:
|
||||
StopActionArea(
|
||||
workstreamId: snapshot.workstreamId,
|
||||
isRowSelected: isSelected,
|
||||
draft: $stopDraft,
|
||||
focusRequest: $stopFocusRequest,
|
||||
onFocusRow: onControlFocus,
|
||||
onActionRow: onControlAction,
|
||||
onBlurRow: onControlBlur,
|
||||
onSend: { text in actions.sendText(snapshot.workstreamId, text) }
|
||||
)
|
||||
@@ -1371,6 +1421,7 @@ private struct PermissionActionArea: View {
|
||||
let toolInputJSON: String
|
||||
let source: WorkstreamSource
|
||||
let status: WorkstreamStatus
|
||||
let onActionRow: () -> Void
|
||||
let onApprove: (WorkstreamPermissionMode) -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -1380,19 +1431,31 @@ private struct PermissionActionArea: View {
|
||||
if status.isPending {
|
||||
HStack(spacing: 6) {
|
||||
FeedButton(label: String(localized: "feed.permission.deny", defaultValue: "Deny"),
|
||||
kind: .dark, size: .medium, fullWidth: true) { onApprove(.deny) }
|
||||
kind: .dark, size: .medium, fullWidth: true) {
|
||||
onActionRow()
|
||||
onApprove(.deny)
|
||||
}
|
||||
.accessibilityIdentifier("FeedPermissionDenyButton")
|
||||
FeedButton(label: String(localized: "feed.permission.once", defaultValue: "Allow Once"),
|
||||
kind: .light, size: .medium, fullWidth: true) { onApprove(.once) }
|
||||
kind: .light, size: .medium, fullWidth: true) {
|
||||
onActionRow()
|
||||
onApprove(.once)
|
||||
}
|
||||
.accessibilityIdentifier("FeedPermissionAllowOnceButton")
|
||||
if FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: source) {
|
||||
FeedButton(label: String(localized: "feed.permission.always", defaultValue: "Always Allow"),
|
||||
kind: .primary, size: .medium, fullWidth: true) { onApprove(.always) }
|
||||
kind: .primary, size: .medium, fullWidth: true) {
|
||||
onActionRow()
|
||||
onApprove(.always)
|
||||
}
|
||||
.accessibilityIdentifier("FeedPermissionAlwaysAllowButton")
|
||||
}
|
||||
if FeedPermissionActionPolicy.supportsBypassPermissions(source: source) {
|
||||
FeedButton(label: String(localized: "feed.permission.bypass", defaultValue: "Bypass"),
|
||||
kind: .destructive, size: .medium, fullWidth: true) { onApprove(.bypass) }
|
||||
kind: .destructive, size: .medium, fullWidth: true) {
|
||||
onActionRow()
|
||||
onApprove(.bypass)
|
||||
}
|
||||
.accessibilityIdentifier("FeedPermissionBypassButton")
|
||||
}
|
||||
}
|
||||
@@ -2135,6 +2198,7 @@ private struct ExitPlanActionArea: View {
|
||||
let status: WorkstreamStatus
|
||||
let isRowSelected: Bool
|
||||
let onFocusRow: () -> Void
|
||||
let onActionRow: () -> Void
|
||||
let onBlurRow: () -> Void
|
||||
let onApprove: (WorkstreamExitPlanMode, String?) -> Void
|
||||
|
||||
@@ -2204,7 +2268,8 @@ private struct ExitPlanActionArea: View {
|
||||
kind: hasFeedback ? .primary : .soft,
|
||||
size: .medium, fullWidth: true
|
||||
) {
|
||||
onFocusRow()
|
||||
feedbackFocused = false
|
||||
onActionRow()
|
||||
// Feedback always wins over mode; hook translates
|
||||
// non-empty feedback into block+reason.
|
||||
onApprove(hasFeedback ? .manual : .ultraplan, hasFeedback ? trimmedFeedback : nil)
|
||||
@@ -2216,7 +2281,8 @@ private struct ExitPlanActionArea: View {
|
||||
size: .medium, fullWidth: true,
|
||||
dimmed: hasFeedback
|
||||
) {
|
||||
onFocusRow()
|
||||
feedbackFocused = false
|
||||
onActionRow()
|
||||
onApprove(.manual, hasFeedback ? trimmedFeedback : nil)
|
||||
}
|
||||
FeedButton(
|
||||
@@ -2226,7 +2292,8 @@ private struct ExitPlanActionArea: View {
|
||||
size: .medium, fullWidth: true,
|
||||
dimmed: hasFeedback
|
||||
) {
|
||||
onFocusRow()
|
||||
feedbackFocused = false
|
||||
onActionRow()
|
||||
onApprove(.autoAccept, hasFeedback ? trimmedFeedback : nil)
|
||||
}
|
||||
}
|
||||
@@ -2542,6 +2609,7 @@ private struct QuestionActionArea: View {
|
||||
let status: WorkstreamStatus
|
||||
let isRowSelected: Bool
|
||||
let onFocusRow: () -> Void
|
||||
let onActionRow: () -> Void
|
||||
let onBlurRow: () -> Void
|
||||
let context: WorkstreamContext?
|
||||
let onReply: ([String]) -> Void
|
||||
@@ -2555,7 +2623,8 @@ private struct QuestionActionArea: View {
|
||||
// non-empty, wins over preset option selections for that
|
||||
// question — mirrors Claude's TUI fallback.
|
||||
@State private var freeTexts: [String: String] = [:]
|
||||
@State private var focusedCustomAnswerId: String?
|
||||
@State private var customAnswerFocusKey: String?
|
||||
@State private var customAnswerFocusRequest = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -2637,7 +2706,7 @@ private struct QuestionActionArea: View {
|
||||
let selected = selections[questionId]?.contains(option.id) == true
|
||||
return Button {
|
||||
guard status.isPending else { return }
|
||||
onFocusRow()
|
||||
onActionRow()
|
||||
clearCustomAnswerFocus()
|
||||
var current = selections[questionId] ?? []
|
||||
if multi {
|
||||
@@ -2712,7 +2781,7 @@ private struct QuestionActionArea: View {
|
||||
)
|
||||
customAnswerField(
|
||||
text: customAnswerBinding(questionId: questionId, multi: multi),
|
||||
isFocused: customAnswerFocusBinding(focusKey),
|
||||
focusRequest: focusRequest(forCustomAnswerKey: focusKey),
|
||||
font: font,
|
||||
onFocus: {
|
||||
onFocusRow()
|
||||
@@ -2741,7 +2810,7 @@ private struct QuestionActionArea: View {
|
||||
guard status.isPending else { return }
|
||||
onFocusRow()
|
||||
selectCustomAnswer(questionId: questionId, multi: multi)
|
||||
focusedCustomAnswerId = focusKey
|
||||
requestCustomAnswerFocus(focusKey)
|
||||
}
|
||||
.feedIBeamCursorOnHover(enabled: status.isPending)
|
||||
.disabled(!status.isPending)
|
||||
@@ -2805,7 +2874,7 @@ private struct QuestionActionArea: View {
|
||||
let font = NSFont.systemFont(ofSize: 11)
|
||||
return customAnswerField(
|
||||
text: customAnswerBinding(questionId: questionId, multi: multi),
|
||||
isFocused: customAnswerFocusBinding(focusKey),
|
||||
focusRequest: focusRequest(forCustomAnswerKey: focusKey),
|
||||
font: font,
|
||||
onFocus: {
|
||||
onFocusRow()
|
||||
@@ -2829,26 +2898,27 @@ private struct QuestionActionArea: View {
|
||||
guard status.isPending else { return }
|
||||
onFocusRow()
|
||||
selectCustomAnswer(questionId: questionId, multi: multi)
|
||||
focusedCustomAnswerId = focusKey
|
||||
requestCustomAnswerFocus(focusKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func customAnswerField(
|
||||
text: Binding<String>,
|
||||
isFocused: Binding<Bool>,
|
||||
focusRequest: Int?,
|
||||
font: NSFont,
|
||||
onFocus: @escaping () -> Void,
|
||||
onBlur: @escaping () -> Void
|
||||
) -> some View {
|
||||
FeedInlineTextField(
|
||||
text: text,
|
||||
isFocused: isFocused,
|
||||
focusRequest: focusRequest,
|
||||
placeholder: String(localized: "feed.question.typeSomething",
|
||||
defaultValue: "Type something..."),
|
||||
isEnabled: status.isPending,
|
||||
font: font,
|
||||
onFocus: onFocus,
|
||||
onBlur: onBlur
|
||||
onBlur: onBlur,
|
||||
onSubmit: nil
|
||||
)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
@@ -2876,23 +2946,19 @@ private struct QuestionActionArea: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func customAnswerFocusBinding(_ focusKey: String) -> Binding<Bool> {
|
||||
Binding<Bool>(
|
||||
get: { focusedCustomAnswerId == focusKey },
|
||||
set: { focused in
|
||||
if focused {
|
||||
focusedCustomAnswerId = focusKey
|
||||
} else if focusedCustomAnswerId == focusKey {
|
||||
focusedCustomAnswerId = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func customAnswerFocusKey(_ questionId: String) -> String {
|
||||
"\(questionId)::custom"
|
||||
}
|
||||
|
||||
private func focusRequest(forCustomAnswerKey focusKey: String) -> Int? {
|
||||
customAnswerFocusKey == focusKey ? customAnswerFocusRequest : nil
|
||||
}
|
||||
|
||||
private func requestCustomAnswerFocus(_ focusKey: String) {
|
||||
customAnswerFocusKey = focusKey
|
||||
customAnswerFocusRequest += 1
|
||||
}
|
||||
|
||||
private func selectCustomAnswer(questionId: String, multi: Bool) {
|
||||
var current = selections[questionId] ?? []
|
||||
if multi {
|
||||
@@ -2904,7 +2970,7 @@ private struct QuestionActionArea: View {
|
||||
}
|
||||
|
||||
private func clearCustomAnswerFocus() {
|
||||
focusedCustomAnswerId = nil
|
||||
customAnswerFocusKey = nil
|
||||
}
|
||||
|
||||
private func optionPill(
|
||||
@@ -2925,7 +2991,7 @@ private struct QuestionActionArea: View {
|
||||
dimmed: !status.isPending
|
||||
) {
|
||||
guard status.isPending else { return }
|
||||
onFocusRow()
|
||||
onActionRow()
|
||||
clearCustomAnswerFocus()
|
||||
var current = selections[questionId] ?? []
|
||||
if multi {
|
||||
@@ -3014,7 +3080,7 @@ private struct QuestionActionArea: View {
|
||||
fullWidth: true,
|
||||
dimmed: !enabled
|
||||
) {
|
||||
onFocusRow()
|
||||
onActionRow()
|
||||
// Selections carry human-readable answer strings (one per
|
||||
// answered question) so the hook can feed them straight
|
||||
// back to the agent as the user's reply.
|
||||
@@ -3031,7 +3097,7 @@ private struct QuestionActionArea: View {
|
||||
size: .medium,
|
||||
fullWidth: true
|
||||
) {
|
||||
onFocusRow()
|
||||
onActionRow()
|
||||
onReply([Self.skipInterviewAndPlanAnswer])
|
||||
}
|
||||
}
|
||||
@@ -3046,6 +3112,7 @@ private final class FeedInlineNativeTextView: NSTextView, FeedKeyboardFocusRespo
|
||||
|
||||
var onActivate: (() -> Void)?
|
||||
var onEscape: (() -> Void)?
|
||||
var onSubmit: (() -> Void)?
|
||||
|
||||
static func blurActiveEditor() {
|
||||
guard let activeEditor else { return }
|
||||
@@ -3086,6 +3153,17 @@ private final class FeedInlineNativeTextView: NSTextView, FeedKeyboardFocusRespo
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
let shouldSubmit = (event.keyCode == 36 || event.keyCode == 76)
|
||||
&& normalizedFlags.intersection([.shift, .option, .command, .control]).isEmpty
|
||||
if shouldSubmit, !hasMarkedText(), let onSubmit {
|
||||
onSubmit()
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
override func resetCursorRects() {
|
||||
super.resetCursorRects()
|
||||
addCursorRect(bounds, cursor: .iBeam)
|
||||
@@ -3250,8 +3328,11 @@ private final class FeedInlineTextEditorView: NSView {
|
||||
)
|
||||
layoutManager.ensureLayout(for: textContainer)
|
||||
let usedRect = layoutManager.usedRect(for: textContainer)
|
||||
let extraLineHeight = layoutManager.extraLineFragmentTextContainer == textContainer
|
||||
? layoutManager.extraLineFragmentRect.height
|
||||
: 0
|
||||
let lineHeight = ceil(currentFont.ascender - currentFont.descender + currentFont.leading)
|
||||
let contentHeight = max(lineHeight, ceil(usedRect.height))
|
||||
let contentHeight = max(lineHeight, ceil(usedRect.height + extraLineHeight))
|
||||
return max(
|
||||
Self.minimumHeight(for: currentFont),
|
||||
ceil(contentHeight + Self.textInset.height * 2)
|
||||
@@ -3279,22 +3360,24 @@ private final class FeedInlineTextEditorView: NSView {
|
||||
|
||||
private struct FeedInlineTextField: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
@Binding var isFocused: Bool
|
||||
|
||||
let focusRequest: Int?
|
||||
let placeholder: String
|
||||
let isEnabled: Bool
|
||||
let font: NSFont
|
||||
let onFocus: () -> Void
|
||||
let onBlur: () -> Void
|
||||
let onSubmit: (() -> Void)?
|
||||
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
var parent: FeedInlineTextField
|
||||
var isProgrammaticMutation = false
|
||||
weak var view: FeedInlineTextEditorView?
|
||||
var pendingFocusRequest: Bool?
|
||||
var lastAppliedFocusRequest: Int?
|
||||
|
||||
init(parent: FeedInlineTextField) {
|
||||
self.parent = parent
|
||||
self.lastAppliedFocusRequest = parent.focusRequest
|
||||
}
|
||||
|
||||
func activateField() {
|
||||
@@ -3302,16 +3385,9 @@ private struct FeedInlineTextField: NSViewRepresentable {
|
||||
dlog("feed.editor.activateField")
|
||||
#endif
|
||||
parent.onFocus()
|
||||
if !parent.isFocused {
|
||||
parent.isFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
func blurField() {
|
||||
pendingFocusRequest = nil
|
||||
if parent.isFocused {
|
||||
parent.isFocused = false
|
||||
}
|
||||
guard let view, let window = view.window, window.firstResponder === view.textView else {
|
||||
return
|
||||
}
|
||||
@@ -3344,9 +3420,6 @@ private struct FeedInlineTextField: NSViewRepresentable {
|
||||
if !isProgrammaticMutation, let textView = notification.object as? NSTextView {
|
||||
parent.text = textView.string
|
||||
}
|
||||
if parent.isFocused {
|
||||
parent.isFocused = false
|
||||
}
|
||||
guard let window = view?.window else {
|
||||
parent.onBlur()
|
||||
return
|
||||
@@ -3373,6 +3446,7 @@ private struct FeedInlineTextField: NSViewRepresentable {
|
||||
view.textView.onEscape = { [weak coordinator = context.coordinator] in
|
||||
coordinator?.blurField()
|
||||
}
|
||||
view.textView.onSubmit = onSubmit
|
||||
configure(view)
|
||||
context.coordinator.view = view
|
||||
return view
|
||||
@@ -3387,6 +3461,7 @@ private struct FeedInlineTextField: NSViewRepresentable {
|
||||
nsView.textView.onEscape = { [weak coordinator = context.coordinator] in
|
||||
coordinator?.blurField()
|
||||
}
|
||||
nsView.textView.onSubmit = onSubmit
|
||||
configure(nsView)
|
||||
|
||||
if nsView.textView.string != text, !nsView.textView.hasMarkedText() {
|
||||
@@ -3397,34 +3472,36 @@ private struct FeedInlineTextField: NSViewRepresentable {
|
||||
}
|
||||
|
||||
guard let window = nsView.window else { return }
|
||||
let firstResponder = window.firstResponder
|
||||
let isFirstResponder = firstResponder === nsView.textView
|
||||
|
||||
if isFocused, isEnabled, !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, coordinator.parent.isEnabled else { return }
|
||||
nsView?.focusIfNeeded()
|
||||
let isFirstResponder = window.firstResponder === nsView.textView
|
||||
if let focusRequest,
|
||||
focusRequest != context.coordinator.lastAppliedFocusRequest {
|
||||
context.coordinator.lastAppliedFocusRequest = focusRequest
|
||||
if isEnabled {
|
||||
nsView.focusIfNeeded()
|
||||
} else if isFirstResponder {
|
||||
moveFocusToFeedHost(in: window)
|
||||
}
|
||||
} else if (!isFocused || !isEnabled), isFirstResponder, context.coordinator.pendingFocusRequest != false {
|
||||
context.coordinator.pendingFocusRequest = false
|
||||
Task { @MainActor [weak nsView, weak coordinator = context.coordinator] in
|
||||
coordinator?.pendingFocusRequest = nil
|
||||
guard let nsView, let window = nsView.window else { return }
|
||||
let stillFocused = window.firstResponder === nsView.textView
|
||||
guard stillFocused else { return }
|
||||
if AppDelegate.shared?.focusRightSidebarInActiveMainWindow(
|
||||
mode: .feed,
|
||||
focusFirstItem: false,
|
||||
preferredWindow: window
|
||||
) != true {
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
} else if focusRequest == nil {
|
||||
context.coordinator.lastAppliedFocusRequest = nil
|
||||
if !isEnabled, isFirstResponder {
|
||||
moveFocusToFeedHost(in: window)
|
||||
}
|
||||
} else if !isEnabled, isFirstResponder {
|
||||
moveFocusToFeedHost(in: window)
|
||||
}
|
||||
}
|
||||
|
||||
private func moveFocusToFeedHost(in window: NSWindow) {
|
||||
if AppDelegate.shared?.focusRightSidebarInActiveMainWindow(
|
||||
mode: .feed,
|
||||
focusFirstItem: false,
|
||||
preferredWindow: window
|
||||
) == true {
|
||||
return
|
||||
}
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
|
||||
private func configure(_ view: FeedInlineTextEditorView) {
|
||||
view.placeholder = placeholder
|
||||
view.apply(font: font, isEnabled: isEnabled)
|
||||
@@ -3442,6 +3519,7 @@ private struct FeedInlineTextField: NSViewRepresentable {
|
||||
nsView.textView.delegate = nil
|
||||
nsView.textView.onActivate = nil
|
||||
nsView.textView.onEscape = nil
|
||||
nsView.textView.onSubmit = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3549,19 +3627,25 @@ private struct FlowLayout: Layout {
|
||||
/// types the reply into the agent's terminal surface and presses
|
||||
/// Return — so the user can reply without switching focus.
|
||||
private struct StopActionArea: View {
|
||||
let workstreamId: String
|
||||
let isRowSelected: Bool
|
||||
@Binding var draft: FeedStopDraft
|
||||
@Binding var focusRequest: Int
|
||||
|
||||
let onFocusRow: () -> Void
|
||||
let onActionRow: () -> Void
|
||||
let onBlurRow: () -> Void
|
||||
let onSend: (String) -> Void
|
||||
|
||||
@State private var reply: String = ""
|
||||
@FocusState private var replyFocused: Bool
|
||||
|
||||
private var trimmed: String {
|
||||
reply.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
draft.reply.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
private var canSend: Bool { !trimmed.isEmpty }
|
||||
private var replyFont: NSFont { NSFont.systemFont(ofSize: 12) }
|
||||
private var replyBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { draft.reply },
|
||||
set: { draft.reply = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -3573,23 +3657,23 @@ private struct StopActionArea: View {
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
TextField(
|
||||
String(localized: "feed.stop.placeholder", defaultValue: "Reply to Claude…"),
|
||||
text: $reply,
|
||||
axis: .vertical
|
||||
FeedInlineTextField(
|
||||
text: replyBinding,
|
||||
focusRequest: focusRequest == 0 ? nil : focusRequest,
|
||||
placeholder: String(localized: "feed.stop.placeholder", defaultValue: "Reply to Claude…"),
|
||||
isEnabled: true,
|
||||
font: replyFont,
|
||||
onFocus: onFocusRow,
|
||||
onBlur: onBlurRow,
|
||||
onSubmit: sendReply
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 12))
|
||||
.focused($replyFocused)
|
||||
.onChange(of: replyFocused) { _, focused in
|
||||
if focused {
|
||||
onFocusRow()
|
||||
} else {
|
||||
onBlurRow()
|
||||
}
|
||||
}
|
||||
.lineLimit(1...5)
|
||||
.padding(10)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: FeedInlineTextEditorView.minimumHeight(for: replyFont),
|
||||
alignment: .leading
|
||||
)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(Color.primary.opacity(0.06))
|
||||
@@ -3598,11 +3682,11 @@ private struct StopActionArea: View {
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.stroke(Color.primary.opacity(canSend ? 0.25 : 0.10), lineWidth: 1)
|
||||
)
|
||||
.onSubmit {
|
||||
if canSend {
|
||||
onSend(trimmed)
|
||||
reply = ""
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.feedIBeamCursorOnHover(enabled: true)
|
||||
.onTapGesture {
|
||||
onFocusRow()
|
||||
requestReplyFocus()
|
||||
}
|
||||
FeedButton(
|
||||
label: String(localized: "feed.stop.send", defaultValue: "Send to Claude"),
|
||||
@@ -3612,17 +3696,22 @@ private struct StopActionArea: View {
|
||||
fullWidth: true,
|
||||
dimmed: !canSend
|
||||
) {
|
||||
onFocusRow()
|
||||
onSend(trimmed)
|
||||
reply = ""
|
||||
}
|
||||
}
|
||||
.onChange(of: isRowSelected) { _, selected in
|
||||
if !selected {
|
||||
replyFocused = false
|
||||
guard canSend else { return }
|
||||
onActionRow()
|
||||
sendReply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestReplyFocus() {
|
||||
focusRequest += 1
|
||||
}
|
||||
|
||||
private func sendReply() {
|
||||
guard canSend else { return }
|
||||
onSend(trimmed)
|
||||
draft.reply = ""
|
||||
}
|
||||
}
|
||||
|
||||
private struct TelemetryActionArea: View {
|
||||
|
||||
@@ -57,8 +57,6 @@ struct FeedHistoryLoadMoreRow: View {
|
||||
let isLoading: Bool
|
||||
let action: () -> Void
|
||||
|
||||
@State private var isVisible = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 6) {
|
||||
@@ -77,17 +75,6 @@ struct FeedHistoryLoadMoreRow: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoading)
|
||||
.onAppear {
|
||||
isVisible = true
|
||||
requestLoadIfVisible()
|
||||
}
|
||||
.onDisappear {
|
||||
isVisible = false
|
||||
}
|
||||
.onChange(of: isLoading) { _, loading in
|
||||
guard !loading else { return }
|
||||
requestLoadIfVisible()
|
||||
}
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
@@ -97,8 +84,4 @@ struct FeedHistoryLoadMoreRow: View {
|
||||
return String(localized: "feed.history.loadOlder", defaultValue: "Load older activity")
|
||||
}
|
||||
|
||||
private func requestLoadIfVisible() {
|
||||
guard isVisible, !isLoading else { return }
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import CMUXWorkstream
|
||||
|
||||
enum FeedPermissionActionPolicy {
|
||||
static func supportsPersistentPermissionModes(source: WorkstreamSource) -> Bool {
|
||||
source != .codex
|
||||
source != .codex && source != .hermesAgent
|
||||
}
|
||||
|
||||
static func supportsBypassPermissions(source: WorkstreamSource) -> Bool {
|
||||
source != .codex && source != .claude
|
||||
source != .codex && source != .claude && source != .hermesAgent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,9 @@ private struct FeedPreviewRootView: View {
|
||||
private struct FeedPreviewCardHost: View {
|
||||
let item: WorkstreamItem
|
||||
|
||||
@State private var stopDraft = FeedStopDraft()
|
||||
@State private var stopFocusRequest = 0
|
||||
|
||||
var body: some View {
|
||||
FeedItemRow(
|
||||
snapshot: FeedItemSnapshot(
|
||||
@@ -129,8 +132,13 @@ private struct FeedPreviewCardHost: View {
|
||||
isSelected: false,
|
||||
onPressSelect: {},
|
||||
onControlFocus: {},
|
||||
onControlAction: {},
|
||||
onControlBlur: {},
|
||||
onActivate: {}
|
||||
onActivate: {},
|
||||
stopDraft: $stopDraft,
|
||||
stopDraftValue: stopDraft,
|
||||
stopFocusRequest: $stopFocusRequest,
|
||||
stopFocusRequestValue: stopFocusRequest
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ final class FeedTextEditorDebugWindowController: NSWindowController, NSWindowDel
|
||||
localized: "feed.textEditorDebug.windowTitle",
|
||||
defaultValue: "Feed Text Editor Lab"
|
||||
)
|
||||
window.identifier = NSUserInterfaceItemIdentifier("cmux.feedTextEditorDebug")
|
||||
window.center()
|
||||
window.contentView = NSHostingView(rootView: FeedTextEditorDebugView())
|
||||
super.init(window: window)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class FileDropHintBadgeView: NSView {
|
||||
private static let neutralBackgroundColor = NSColor.systemGray.withAlphaComponent(0.20)
|
||||
private let effectView: NSView
|
||||
private let label = NSTextField(labelWithString: "")
|
||||
private var animationGeneration: UInt64 = 0
|
||||
|
||||
override var isOpaque: Bool { false }
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
if let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type {
|
||||
effectView = glassClass.init(frame: .zero)
|
||||
} else {
|
||||
let visualEffect = NSVisualEffectView(frame: .zero)
|
||||
visualEffect.material = .hudWindow
|
||||
visualEffect.blendingMode = .withinWindow
|
||||
visualEffect.state = .active
|
||||
effectView = visualEffect
|
||||
}
|
||||
|
||||
super.init(frame: frameRect)
|
||||
frame.size = CGSize(width: 140, height: 26)
|
||||
|
||||
wantsLayer = true
|
||||
layer?.backgroundColor = NSColor.clear.cgColor
|
||||
alphaValue = 0
|
||||
isHidden = true
|
||||
|
||||
effectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
effectView.wantsLayer = true
|
||||
effectView.layer?.backgroundColor = Self.neutralBackgroundColor.cgColor
|
||||
effectView.layer?.cornerRadius = 13
|
||||
effectView.layer?.masksToBounds = true
|
||||
configureNativeGlassIfNeeded(effectView)
|
||||
addSubview(effectView)
|
||||
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
label.textColor = .labelColor
|
||||
label.alignment = .center
|
||||
label.lineBreakMode = .byClipping
|
||||
label.maximumNumberOfLines = 1
|
||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
addSubview(label, positioned: .above, relativeTo: effectView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
effectView.topAnchor.constraint(equalTo: topAnchor),
|
||||
effectView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
effectView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
effectView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
|
||||
label.topAnchor.constraint(equalTo: topAnchor, constant: 5),
|
||||
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5),
|
||||
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
|
||||
label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
nil
|
||||
}
|
||||
|
||||
func show(text: String, centeredIn targetBounds: CGRect, clippedTo bounds: CGRect) {
|
||||
animationGeneration &+= 1
|
||||
label.stringValue = text
|
||||
let fitting = label.intrinsicContentSize
|
||||
let maxWidth = max(80, min(bounds.width, targetBounds.width) - 16)
|
||||
let width = min(max(140, fitting.width + 20), maxWidth)
|
||||
let height: CGFloat = 26
|
||||
let origin = CGPoint(
|
||||
x: min(max(targetBounds.midX - width / 2, bounds.minX + 8), max(bounds.minX + 8, bounds.maxX - width - 8)),
|
||||
y: min(max(targetBounds.midY - height / 2, bounds.minY + 8), max(bounds.minY + 8, bounds.maxY - height - 8))
|
||||
)
|
||||
frame = CGRect(origin: origin, size: CGSize(width: width, height: height))
|
||||
if isHidden {
|
||||
alphaValue = 0
|
||||
isHidden = false
|
||||
animator().alphaValue = 1
|
||||
} else {
|
||||
alphaValue = 1
|
||||
}
|
||||
}
|
||||
|
||||
func hide() {
|
||||
guard !isHidden else { return }
|
||||
animationGeneration &+= 1
|
||||
let generation = animationGeneration
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.12
|
||||
animator().alphaValue = 0
|
||||
} completionHandler: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
guard self.animationGeneration == generation else { return }
|
||||
self.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureNativeGlassIfNeeded(_ view: NSView) {
|
||||
guard view.className == "NSGlassEffectView" else { return }
|
||||
|
||||
let tintSelector = NSSelectorFromString("setTintColor:")
|
||||
if view.responds(to: tintSelector) {
|
||||
view.perform(tintSelector, with: Self.neutralBackgroundColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import AppKit
|
||||
import Bonsplit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
protocol FileDropPaneTarget: AnyObject {
|
||||
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation
|
||||
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation
|
||||
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?)
|
||||
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool
|
||||
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool
|
||||
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?)
|
||||
}
|
||||
|
||||
extension PaneDropTargetView: FileDropPaneTarget {
|
||||
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) }
|
||||
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) }
|
||||
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) }
|
||||
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) }
|
||||
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) }
|
||||
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) }
|
||||
}
|
||||
|
||||
extension BrowserPaneDropTargetView: FileDropPaneTarget {
|
||||
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) }
|
||||
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) }
|
||||
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) }
|
||||
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) }
|
||||
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) }
|
||||
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) }
|
||||
}
|
||||
|
||||
/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to
|
||||
/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's
|
||||
/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply
|
||||
/// embedded terminal views. This overlay sits above the entire content view hierarchy and
|
||||
/// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor.
|
||||
///
|
||||
/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks,
|
||||
/// scrolls, and other interactions pass through normally.
|
||||
final class FileDropOverlayView: NSView {
|
||||
/// Fallback handler when no terminal is found under the drop point.
|
||||
var onDrop: (([URL]) -> Bool)?
|
||||
private var isForwardingMouseEvent = false
|
||||
private weak var forwardedMouseDragTarget: NSView?
|
||||
private var forwardedMouseDragButton: ForwardedMouseDragButton?
|
||||
/// The WKWebView currently receiving forwarded drag events, so we can
|
||||
/// synthesize draggingExited/draggingEntered as the cursor moves.
|
||||
weak var activeDragWebView: WKWebView?
|
||||
/// The WKWebView that accepted prepareForDragOperation so conclude can be
|
||||
/// delivered to the same browser target after the drop completes.
|
||||
weak var preparedDragWebView: WKWebView?
|
||||
/// Pane drop target currently receiving delegated file drag events.
|
||||
weak var activePaneDropTarget: (any FileDropPaneTarget)?
|
||||
/// Pane drop target that accepted prepareForDragOperation.
|
||||
weak var preparedPaneDropTarget: (any FileDropPaneTarget)?
|
||||
var didPerformDragAsText = false
|
||||
weak var performedTextDragWebView: WKWebView?
|
||||
weak var performedTextPaneDropTarget: (any FileDropPaneTarget)?
|
||||
let hintBadgeView = FileDropHintBadgeView(frame: .zero)
|
||||
var lastHitTestLogSignature: String?
|
||||
var lastDragRouteLogSignatureByPhase: [String: String] = [:]
|
||||
|
||||
override var acceptsFirstResponder: Bool { false }
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
registerForDraggedTypes(Array(PasteboardFileURLReader.fileURLPasteboardTypes))
|
||||
addSubview(hintBadgeView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }
|
||||
|
||||
private enum ForwardedMouseDragButton: Equatable {
|
||||
case left
|
||||
case right
|
||||
case other(Int)
|
||||
}
|
||||
|
||||
private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? {
|
||||
switch event.type {
|
||||
case .leftMouseDown, .leftMouseUp, .leftMouseDragged:
|
||||
return .left
|
||||
case .rightMouseDown, .rightMouseUp, .rightMouseDragged:
|
||||
return .right
|
||||
case .otherMouseDown, .otherMouseUp, .otherMouseDragged:
|
||||
return .other(Int(event.buttonNumber))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool {
|
||||
switch eventType {
|
||||
case .leftMouseDown, .rightMouseDown, .otherMouseDown:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool {
|
||||
switch eventType {
|
||||
case .leftMouseUp, .rightMouseUp, .otherMouseUp:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so
|
||||
// file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict.
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
let pb = NSPasteboard(name: .drag)
|
||||
let eventType = NSApp.currentEvent?.type
|
||||
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay(
|
||||
pasteboardTypes: pb.types,
|
||||
eventType: eventType
|
||||
)
|
||||
#if DEBUG
|
||||
logHitTestDecision(
|
||||
pasteboardTypes: pb.types,
|
||||
eventType: eventType,
|
||||
shouldCapture: shouldCapture
|
||||
)
|
||||
#endif
|
||||
guard shouldCapture else { return nil }
|
||||
if shouldDeferFileDropOverlayToBonsplitTabBar(at: point) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return super.hitTest(point)
|
||||
}
|
||||
|
||||
// MARK: Mouse forwarding — safety net for the rare case where stale drag pasteboard
|
||||
// data causes hitTest to return self when no drag is actually active.
|
||||
// We hit-test contentView directly and dispatch to the target rather than using
|
||||
// window.sendEvent(), which caches the mouse target and causes infinite recursion.
|
||||
|
||||
private func forwardEvent(_ event: NSEvent) {
|
||||
guard !isForwardingMouseEvent else { return }
|
||||
guard let window, let contentView = window.contentView else { return }
|
||||
let eventButton = dragButton(for: event)
|
||||
|
||||
isForwardingMouseEvent = true
|
||||
isHidden = true
|
||||
defer {
|
||||
isHidden = false
|
||||
isForwardingMouseEvent = false
|
||||
}
|
||||
|
||||
let target: NSView?
|
||||
if let eventButton,
|
||||
forwardedMouseDragButton == eventButton,
|
||||
let activeTarget = forwardedMouseDragTarget,
|
||||
activeTarget.window != nil {
|
||||
// Preserve normal AppKit mouse-delivery semantics: once a drag starts,
|
||||
// keep routing dragged/up events to the original mouseDown target.
|
||||
target = activeTarget
|
||||
} else {
|
||||
let point = contentView.convert(event.locationInWindow, from: nil)
|
||||
target = contentView.hitTest(point)
|
||||
}
|
||||
|
||||
guard let target, target !== self else {
|
||||
if shouldTrackForwardedMouseDragEnd(for: event.type),
|
||||
let eventButton,
|
||||
forwardedMouseDragButton == eventButton {
|
||||
forwardedMouseDragTarget = nil
|
||||
forwardedMouseDragButton = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton {
|
||||
forwardedMouseDragTarget = target
|
||||
forwardedMouseDragButton = eventButton
|
||||
}
|
||||
|
||||
switch event.type {
|
||||
case .leftMouseDown: target.mouseDown(with: event)
|
||||
case .leftMouseUp: target.mouseUp(with: event)
|
||||
case .leftMouseDragged: target.mouseDragged(with: event)
|
||||
case .rightMouseDown: target.rightMouseDown(with: event)
|
||||
case .rightMouseUp: target.rightMouseUp(with: event)
|
||||
case .rightMouseDragged: target.rightMouseDragged(with: event)
|
||||
case .otherMouseDown: target.otherMouseDown(with: event)
|
||||
case .otherMouseUp: target.otherMouseUp(with: event)
|
||||
case .otherMouseDragged: target.otherMouseDragged(with: event)
|
||||
case .scrollWheel: target.scrollWheel(with: event)
|
||||
default: break
|
||||
}
|
||||
|
||||
if shouldTrackForwardedMouseDragEnd(for: event.type),
|
||||
let eventButton,
|
||||
forwardedMouseDragButton == eventButton {
|
||||
forwardedMouseDragTarget = nil
|
||||
forwardedMouseDragButton = nil
|
||||
}
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) { forwardEvent(event) }
|
||||
override func mouseUp(with event: NSEvent) { forwardEvent(event) }
|
||||
override func mouseDragged(with event: NSEvent) { forwardEvent(event) }
|
||||
override func rightMouseDown(with event: NSEvent) { forwardEvent(event) }
|
||||
override func rightMouseUp(with event: NSEvent) { forwardEvent(event) }
|
||||
override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) }
|
||||
override func otherMouseDown(with event: NSEvent) { forwardEvent(event) }
|
||||
override func otherMouseUp(with event: NSEvent) { forwardEvent(event) }
|
||||
override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) }
|
||||
override func scrollWheel(with event: NSEvent) { forwardEvent(event) }
|
||||
|
||||
// MARK: NSDraggingDestination – accept file drops over terminal and browser views.
|
||||
//
|
||||
// AppKit sends draggingEntered once when the drag enters this overlay, then
|
||||
// draggingUpdated as the cursor moves within it. We track which WKWebView (if
|
||||
// any) is under the cursor and synthesize enter/exit calls so the browser's
|
||||
// HTML5 drag events (dragenter, dragleave, drop) fire correctly.
|
||||
|
||||
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||
return updateDragTarget(sender, phase: "entered")
|
||||
}
|
||||
|
||||
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||
return updateDragTarget(sender, phase: "updated")
|
||||
}
|
||||
|
||||
override func draggingExited(_ sender: (any NSDraggingInfo)?) {
|
||||
hintBadgeView.hide()
|
||||
preparedDragWebView = nil
|
||||
preparedPaneDropTarget = nil
|
||||
didPerformDragAsText = false
|
||||
performedTextDragWebView = nil
|
||||
performedTextPaneDropTarget = nil
|
||||
exitActiveDragTargets(sender)
|
||||
}
|
||||
|
||||
private func exitActiveDragTargets(_ sender: (any NSDraggingInfo)?) {
|
||||
if let prev = activeDragWebView {
|
||||
prev.draggingExited(sender)
|
||||
activeDragWebView = nil
|
||||
}
|
||||
if let prev = activePaneDropTarget {
|
||||
prev.fileDropDraggingExited(sender)
|
||||
activePaneDropTarget = nil
|
||||
}
|
||||
}
|
||||
|
||||
override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
||||
let hasLocalDraggingSource = sender.draggingSource != nil
|
||||
let types = sender.draggingPasteboard.types
|
||||
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
|
||||
pasteboardTypes: types,
|
||||
hasLocalDraggingSource: hasLocalDraggingSource
|
||||
)
|
||||
if shouldRouteFileDropToTextDestination(sender) {
|
||||
let paneDropTarget = activePaneDropTarget ?? paneDropTargetForTextDrop(at: sender.draggingLocation)
|
||||
exitActiveDragTargets(sender)
|
||||
preparedDragWebView = nil
|
||||
if let paneDropTarget {
|
||||
let accepted = paneDropTarget.fileDropPrepareForDragOperation(sender)
|
||||
preparedPaneDropTarget = accepted ? paneDropTarget : nil
|
||||
return accepted
|
||||
}
|
||||
preparedPaneDropTarget = nil
|
||||
if let webView = webViewUnderPoint(sender.draggingLocation) {
|
||||
let accepted = webView.prepareForDragOperation(sender)
|
||||
preparedDragWebView = accepted ? webView : nil
|
||||
return accepted
|
||||
}
|
||||
return textDropDestinationKindUnderPoint(sender.draggingLocation) != nil
|
||||
}
|
||||
let paneDropTarget = shouldCapture
|
||||
? (activePaneDropTarget ?? paneDropTargetUnderPoint(sender.draggingLocation))
|
||||
: nil
|
||||
let terminal = paneDropTarget == nil ? terminalUnderPoint(sender.draggingLocation) : nil
|
||||
let webView = paneDropTarget == nil && terminal == nil ? webViewUnderPoint(sender.draggingLocation) : nil
|
||||
let hasPaneTarget = paneDropTarget != nil || terminal != nil || webView != nil
|
||||
#if DEBUG
|
||||
logDragRouteDecision(
|
||||
phase: "prepare",
|
||||
pasteboardTypes: types,
|
||||
shouldCapture: shouldCapture,
|
||||
hasLocalDraggingSource: hasLocalDraggingSource,
|
||||
hasPaneTarget: hasPaneTarget
|
||||
)
|
||||
#endif
|
||||
guard shouldCapture else {
|
||||
preparedDragWebView = nil
|
||||
preparedPaneDropTarget = nil
|
||||
exitActiveDragTargets(sender)
|
||||
return false
|
||||
}
|
||||
preparedDragWebView = nil
|
||||
if let paneDropTarget {
|
||||
let accepted = paneDropTarget.fileDropPrepareForDragOperation(sender)
|
||||
preparedPaneDropTarget = accepted ? paneDropTarget : nil
|
||||
return accepted
|
||||
}
|
||||
preparedPaneDropTarget = nil
|
||||
if let webView {
|
||||
let accepted = webView.prepareForDragOperation(sender)
|
||||
preparedDragWebView = accepted ? webView : nil
|
||||
return accepted
|
||||
}
|
||||
return hasPaneTarget
|
||||
}
|
||||
|
||||
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
||||
let hasLocalDraggingSource = sender.draggingSource != nil
|
||||
let types = sender.draggingPasteboard.types
|
||||
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
|
||||
pasteboardTypes: types,
|
||||
hasLocalDraggingSource: hasLocalDraggingSource
|
||||
)
|
||||
if shouldRouteFileDropToTextDestination(sender) {
|
||||
hintBadgeView.hide()
|
||||
didPerformDragAsText = false
|
||||
performedTextDragWebView = nil
|
||||
performedTextPaneDropTarget = nil
|
||||
let paneDropTarget = preparedPaneDropTarget ?? activePaneDropTarget ?? paneDropTargetForTextDrop(at: sender.draggingLocation)
|
||||
let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)
|
||||
exitActiveDragTargets(sender)
|
||||
preparedDragWebView = nil
|
||||
if let paneDropTarget {
|
||||
let handled = paneDropTarget.fileDropPerformDragOperation(sender)
|
||||
if handled {
|
||||
didPerformDragAsText = true
|
||||
performedTextPaneDropTarget = paneDropTarget
|
||||
} else {
|
||||
preparedPaneDropTarget = nil
|
||||
}
|
||||
return handled
|
||||
}
|
||||
if let webView {
|
||||
let handled = webView.performDragOperation(sender)
|
||||
if !handled {
|
||||
preparedDragWebView = nil
|
||||
performedTextDragWebView = nil
|
||||
} else {
|
||||
didPerformDragAsText = true
|
||||
performedTextDragWebView = webView
|
||||
}
|
||||
return handled
|
||||
}
|
||||
let handled = performFileDropAsText(sender)
|
||||
didPerformDragAsText = handled
|
||||
return handled
|
||||
}
|
||||
didPerformDragAsText = false
|
||||
performedTextDragWebView = nil
|
||||
performedTextPaneDropTarget = nil
|
||||
let paneDropTarget = shouldCapture
|
||||
? (preparedPaneDropTarget ?? activePaneDropTarget ?? paneDropTargetUnderPoint(sender.draggingLocation))
|
||||
: nil
|
||||
let terminal = paneDropTarget == nil ? terminalUnderPoint(sender.draggingLocation) : nil
|
||||
let webView = paneDropTarget == nil && terminal == nil
|
||||
? (preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation))
|
||||
: nil
|
||||
let hasPaneTarget = paneDropTarget != nil || terminal != nil || webView != nil
|
||||
#if DEBUG
|
||||
logDragRouteDecision(
|
||||
phase: "perform",
|
||||
pasteboardTypes: types,
|
||||
shouldCapture: shouldCapture,
|
||||
hasLocalDraggingSource: hasLocalDraggingSource,
|
||||
hasPaneTarget: hasPaneTarget
|
||||
)
|
||||
#endif
|
||||
guard shouldCapture else {
|
||||
preparedDragWebView = nil
|
||||
preparedPaneDropTarget = nil
|
||||
exitActiveDragTargets(sender)
|
||||
return false
|
||||
}
|
||||
preparedDragWebView = nil
|
||||
if let paneDropTarget {
|
||||
let handled = paneDropTarget.fileDropPerformDragOperation(sender)
|
||||
if !handled {
|
||||
preparedPaneDropTarget = nil
|
||||
activePaneDropTarget = nil
|
||||
}
|
||||
return handled
|
||||
}
|
||||
preparedPaneDropTarget = nil
|
||||
if let webView {
|
||||
let handled = webView.performDragOperation(sender)
|
||||
if !handled {
|
||||
preparedDragWebView = nil
|
||||
activeDragWebView = nil
|
||||
}
|
||||
return handled
|
||||
}
|
||||
activeDragWebView = nil
|
||||
guard let terminal else { return false }
|
||||
return terminal.performDragOperation(sender)
|
||||
}
|
||||
|
||||
override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) {
|
||||
defer {
|
||||
hintBadgeView.hide()
|
||||
preparedDragWebView = nil
|
||||
activeDragWebView = nil
|
||||
preparedPaneDropTarget = nil
|
||||
activePaneDropTarget = nil
|
||||
didPerformDragAsText = false
|
||||
performedTextDragWebView = nil
|
||||
performedTextPaneDropTarget = nil
|
||||
}
|
||||
guard let sender else { return }
|
||||
if didPerformDragAsText {
|
||||
if let paneDropTarget = performedTextPaneDropTarget ?? preparedPaneDropTarget ?? activePaneDropTarget {
|
||||
paneDropTarget.fileDropConcludeDragOperation(sender)
|
||||
} else if let webView = performedTextDragWebView {
|
||||
webView.concludeDragOperation(sender)
|
||||
}
|
||||
exitActiveDragTargets(sender)
|
||||
return
|
||||
}
|
||||
guard DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
|
||||
pasteboardTypes: sender.draggingPasteboard.types,
|
||||
hasLocalDraggingSource: sender.draggingSource != nil
|
||||
) else {
|
||||
return
|
||||
}
|
||||
if let paneDropTarget = preparedPaneDropTarget ?? activePaneDropTarget {
|
||||
paneDropTarget.fileDropConcludeDragOperation(sender)
|
||||
return
|
||||
}
|
||||
if let webView = preparedDragWebView ?? activeDragWebView {
|
||||
webView.concludeDragOperation(sender)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import AppKit
|
||||
import Bonsplit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
extension FileDropOverlayView {
|
||||
func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation {
|
||||
let loc = sender.draggingLocation
|
||||
let hasLocalDraggingSource = sender.draggingSource != nil
|
||||
let types = sender.draggingPasteboard.types
|
||||
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
|
||||
pasteboardTypes: types,
|
||||
hasLocalDraggingSource: hasLocalDraggingSource
|
||||
)
|
||||
updateHintBadge(sender: sender, pasteboardTypes: types)
|
||||
|
||||
if shouldRouteFileDropToTextDestination(sender) {
|
||||
let paneDropTarget = paneDropTargetForTextDrop(at: loc)
|
||||
if let prev = activePaneDropTarget {
|
||||
if fileDropPaneTargetsAreIdentical(prev, paneDropTarget) {
|
||||
return prev.fileDropDraggingUpdated(sender)
|
||||
}
|
||||
prev.fileDropDraggingExited(sender)
|
||||
activePaneDropTarget = nil
|
||||
}
|
||||
if let paneDropTarget {
|
||||
if let prev = activeDragWebView {
|
||||
prev.draggingExited(sender)
|
||||
activeDragWebView = nil
|
||||
}
|
||||
activePaneDropTarget = paneDropTarget
|
||||
return paneDropTarget.fileDropDraggingEntered(sender)
|
||||
}
|
||||
if let webView = webViewUnderPoint(loc) {
|
||||
if activeDragWebView !== webView {
|
||||
if let prev = activeDragWebView {
|
||||
prev.draggingExited(sender)
|
||||
}
|
||||
activeDragWebView = webView
|
||||
return webView.draggingEntered(sender)
|
||||
}
|
||||
return webView.draggingUpdated(sender)
|
||||
}
|
||||
if let prev = activeDragWebView {
|
||||
prev.draggingExited(sender)
|
||||
activeDragWebView = nil
|
||||
}
|
||||
return textDropDestinationKindUnderPoint(loc) == nil
|
||||
? []
|
||||
: DragOverlayRoutingPolicy.textDropOperation(pasteboardTypes: types)
|
||||
}
|
||||
|
||||
let paneDropTarget = shouldCapture ? paneDropTargetUnderPoint(loc) : nil
|
||||
let webView = shouldCapture && paneDropTarget == nil ? webViewUnderPoint(loc) : nil
|
||||
|
||||
if let prev = activeDragWebView {
|
||||
if prev !== webView {
|
||||
prev.draggingExited(sender)
|
||||
activeDragWebView = nil
|
||||
}
|
||||
}
|
||||
if let prev = activePaneDropTarget,
|
||||
!fileDropPaneTargetsAreIdentical(prev, paneDropTarget) {
|
||||
prev.fileDropDraggingExited(sender)
|
||||
activePaneDropTarget = nil
|
||||
}
|
||||
|
||||
if let paneDropTarget {
|
||||
if !fileDropPaneTargetsAreIdentical(activePaneDropTarget, paneDropTarget) {
|
||||
activePaneDropTarget = paneDropTarget
|
||||
return paneDropTarget.fileDropDraggingEntered(sender)
|
||||
}
|
||||
return paneDropTarget.fileDropDraggingUpdated(sender)
|
||||
}
|
||||
|
||||
if let webView {
|
||||
if activeDragWebView !== webView {
|
||||
activeDragWebView = webView
|
||||
return webView.draggingEntered(sender)
|
||||
}
|
||||
return webView.draggingUpdated(sender)
|
||||
}
|
||||
|
||||
let hasPaneTarget = terminalUnderPoint(loc) != nil
|
||||
#if DEBUG
|
||||
logDragRouteDecision(
|
||||
phase: phase,
|
||||
pasteboardTypes: types,
|
||||
shouldCapture: shouldCapture,
|
||||
hasLocalDraggingSource: hasLocalDraggingSource,
|
||||
hasPaneTarget: hasPaneTarget
|
||||
)
|
||||
#endif
|
||||
guard shouldCapture, hasPaneTarget else { return [] }
|
||||
return .copy
|
||||
}
|
||||
|
||||
private func fileDropPaneTargetsAreIdentical(
|
||||
_ lhs: (any FileDropPaneTarget)?,
|
||||
_ rhs: (any FileDropPaneTarget)?
|
||||
) -> Bool {
|
||||
guard let lhs, let rhs else { return lhs == nil && rhs == nil }
|
||||
return (lhs as AnyObject) === (rhs as AnyObject)
|
||||
}
|
||||
|
||||
private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String {
|
||||
guard let types, !types.isEmpty else { return "-" }
|
||||
return types.map(\.rawValue).joined(separator: ",")
|
||||
}
|
||||
|
||||
func shouldRouteFileDropToTextDestination(_ sender: any NSDraggingInfo) -> Bool {
|
||||
let canDropAsText = textDropDestinationKindUnderPoint(sender.draggingLocation) != nil
|
||||
return DragOverlayRoutingPolicy.shouldRouteFileDropToTextDestination(
|
||||
pasteboardTypes: sender.draggingPasteboard.types,
|
||||
modifierFlags: DragOverlayRoutingPolicy.currentModifierFlags,
|
||||
canDropAsText: canDropAsText
|
||||
)
|
||||
}
|
||||
|
||||
private func updateHintBadge(
|
||||
sender: any NSDraggingInfo,
|
||||
pasteboardTypes: [NSPasteboard.PasteboardType]?
|
||||
) {
|
||||
let kind = textDropDestinationKindUnderPoint(sender.draggingLocation)
|
||||
guard let alternateBehavior = DragOverlayRoutingPolicy.alternateFileDropBehaviorForShiftHint(
|
||||
pasteboardTypes: pasteboardTypes,
|
||||
modifierFlags: DragOverlayRoutingPolicy.currentModifierFlags,
|
||||
canDropAsText: kind != nil
|
||||
), let kind,
|
||||
let hintText = kind.hintText(for: alternateBehavior),
|
||||
let targetBounds = hintBadgeTargetBoundsUnderPoint(sender.draggingLocation) else {
|
||||
hintBadgeView.hide()
|
||||
return
|
||||
}
|
||||
hintBadgeView.show(text: hintText, centeredIn: targetBounds, clippedTo: bounds)
|
||||
}
|
||||
|
||||
func textDropDestinationKindUnderPoint(_ windowPoint: NSPoint) -> FileDropTextDestinationKind? {
|
||||
if editableTextViewUnderPoint(windowPoint) != nil {
|
||||
return .editor
|
||||
}
|
||||
if webViewUnderPoint(windowPoint) != nil {
|
||||
return .editor
|
||||
}
|
||||
if terminalUnderPoint(windowPoint) != nil {
|
||||
return .terminal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func hintBadgeTargetBoundsUnderPoint(_ windowPoint: NSPoint) -> CGRect? {
|
||||
if let paneDropTarget = paneDropTargetUnderPoint(windowPoint),
|
||||
let targetView = paneDropTarget as? NSView {
|
||||
return convert(targetView.bounds, from: targetView)
|
||||
}
|
||||
if let terminal = terminalUnderPoint(windowPoint) {
|
||||
return convert(terminal.bounds, from: terminal)
|
||||
}
|
||||
if let webView = webViewUnderPoint(windowPoint) {
|
||||
return convert(webView.bounds, from: webView)
|
||||
}
|
||||
if let textView = editableTextViewUnderPoint(windowPoint) {
|
||||
return convert(textView.visibleRect, from: textView)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func performFileDropAsText(_ sender: any NSDraggingInfo) -> Bool {
|
||||
let urls = DragOverlayRoutingPolicy.fileURLs(from: sender.draggingPasteboard)
|
||||
guard !urls.isEmpty else { return false }
|
||||
|
||||
let windowPoint = sender.draggingLocation
|
||||
if let textView = editableTextViewUnderPoint(windowPoint) {
|
||||
let text = TerminalImageTransferPlanner.insertedText(forFileURLs: urls)
|
||||
guard !text.isEmpty else { return false }
|
||||
return insert(text, into: textView)
|
||||
}
|
||||
if let terminal = terminalUnderPoint(windowPoint) {
|
||||
return insert(urls, into: terminal)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func viewUnderPoint(_ windowPoint: NSPoint) -> NSView? {
|
||||
guard let window, let contentView = window.contentView else { return nil }
|
||||
isHidden = true
|
||||
defer { isHidden = false }
|
||||
let point = contentView.convert(windowPoint, from: nil)
|
||||
return contentView.hitTest(point)
|
||||
}
|
||||
|
||||
private func editableTextViewUnderPoint(_ windowPoint: NSPoint) -> NSTextView? {
|
||||
var current = viewUnderPoint(windowPoint)
|
||||
while let view = current {
|
||||
if let textView = view as? NSTextView, textView.isEditable {
|
||||
return textView
|
||||
}
|
||||
if let textField = view as? NSTextField,
|
||||
textField.isEditable,
|
||||
let editor = textField.currentEditor() as? NSTextView {
|
||||
return editor
|
||||
}
|
||||
current = view.superview
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func insert(_ text: String, into textView: NSTextView) -> Bool {
|
||||
guard textView.isEditable else { return false }
|
||||
textView.window?.makeFirstResponder(textView)
|
||||
textView.insertText(text, replacementRange: textView.selectedRange())
|
||||
return true
|
||||
}
|
||||
|
||||
private func insert(_ urls: [URL], into terminal: GhosttyNSView) -> Bool {
|
||||
FileDropTextDropController.performTerminalFileDrop(
|
||||
terminal: terminal,
|
||||
urls: urls
|
||||
)
|
||||
}
|
||||
|
||||
/// Hit-tests the window to find a WKWebView (browser panel) under the cursor.
|
||||
func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? {
|
||||
if let window,
|
||||
let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) {
|
||||
return portalWebView
|
||||
}
|
||||
|
||||
guard let window, let contentView = window.contentView else { return nil }
|
||||
isHidden = true
|
||||
defer { isHidden = false }
|
||||
let point = contentView.convert(windowPoint, from: nil)
|
||||
let hitView = contentView.hitTest(point)
|
||||
|
||||
var current: NSView? = hitView
|
||||
while let view = current {
|
||||
if let webView = view as? WKWebView { return webView }
|
||||
current = view.superview
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func debugTopHitViewForCurrentEvent() -> String {
|
||||
guard let window,
|
||||
let currentEvent = NSApp.currentEvent,
|
||||
let contentView = window.contentView,
|
||||
let themeFrame = contentView.superview else { return "-" }
|
||||
|
||||
let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil)
|
||||
// Don't toggle isHidden here — it triggers setNeedsDisplay which can
|
||||
// exceed AppKit's display-pass limit during cursor-update display cycles.
|
||||
guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" }
|
||||
var chain: [String] = []
|
||||
var current: NSView? = hit
|
||||
var depth = 0
|
||||
while let view = current, depth < 6 {
|
||||
chain.append(debugHitViewDescriptor(view))
|
||||
current = view.superview
|
||||
depth += 1
|
||||
}
|
||||
return chain.joined(separator: "->")
|
||||
}
|
||||
|
||||
private func debugHitViewDescriptor(_ view: NSView) -> String {
|
||||
let className = String(describing: type(of: view))
|
||||
let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque())
|
||||
let dragTypes = debugRegisteredDragTypes(view)
|
||||
return "\(className)@\(ptr){dragTypes=\(dragTypes)}"
|
||||
}
|
||||
|
||||
private func debugRegisteredDragTypes(_ view: NSView) -> String {
|
||||
let types = view.registeredDraggedTypes
|
||||
guard !types.isEmpty else { return "-" }
|
||||
|
||||
let interestingTypes = types.filter { type in
|
||||
let raw = type.rawValue
|
||||
return PasteboardFileURLReader.fileURLPasteboardTypes.contains(type)
|
||||
|| raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue
|
||||
|| raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue
|
||||
|| raw.contains("public.text")
|
||||
|| raw.contains("public.url")
|
||||
|| raw.contains("public.data")
|
||||
}
|
||||
let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes
|
||||
let rendered = selected.map(\.rawValue).joined(separator: ",")
|
||||
if selected.count < types.count {
|
||||
return "\(rendered),+\(types.count - selected.count)"
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool {
|
||||
guard let types else { return false }
|
||||
return DragOverlayRoutingPolicy.hasFileDropPayload(types)
|
||||
|| types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType)
|
||||
|| types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType)
|
||||
}
|
||||
|
||||
private func debugEventName(_ eventType: NSEvent.EventType?) -> String {
|
||||
guard let eventType else { return "none" }
|
||||
switch eventType {
|
||||
case .cursorUpdate: return "cursorUpdate"
|
||||
case .appKitDefined: return "appKitDefined"
|
||||
case .systemDefined: return "systemDefined"
|
||||
case .applicationDefined: return "applicationDefined"
|
||||
case .periodic: return "periodic"
|
||||
case .mouseMoved: return "mouseMoved"
|
||||
case .mouseEntered: return "mouseEntered"
|
||||
case .mouseExited: return "mouseExited"
|
||||
case .flagsChanged: return "flagsChanged"
|
||||
case .leftMouseDown: return "leftMouseDown"
|
||||
case .leftMouseUp: return "leftMouseUp"
|
||||
case .leftMouseDragged: return "leftMouseDragged"
|
||||
case .rightMouseDown: return "rightMouseDown"
|
||||
case .rightMouseUp: return "rightMouseUp"
|
||||
case .rightMouseDragged: return "rightMouseDragged"
|
||||
case .otherMouseDown: return "otherMouseDown"
|
||||
case .otherMouseUp: return "otherMouseUp"
|
||||
case .otherMouseDragged: return "otherMouseDragged"
|
||||
case .scrollWheel: return "scrollWheel"
|
||||
default: return "other(\(eventType.rawValue))"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func logHitTestDecision(
|
||||
pasteboardTypes: [NSPasteboard.PasteboardType]?,
|
||||
eventType: NSEvent.EventType?,
|
||||
shouldCapture: Bool
|
||||
) {
|
||||
let isDragEvent = eventType == .leftMouseDragged
|
||||
|| eventType == .rightMouseDragged
|
||||
|| eventType == .otherMouseDragged
|
||||
guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return }
|
||||
|
||||
let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))"
|
||||
guard lastHitTestLogSignature != signature else { return }
|
||||
lastHitTestLogSignature = signature
|
||||
cmuxDebugLog(
|
||||
"overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " +
|
||||
"event=\(debugEventName(eventType)) " +
|
||||
"topHit=\(debugTopHitViewForCurrentEvent()) " +
|
||||
"types=\(debugPasteboardTypes(pasteboardTypes))"
|
||||
)
|
||||
}
|
||||
|
||||
func logDragRouteDecision(
|
||||
phase: String,
|
||||
pasteboardTypes: [NSPasteboard.PasteboardType]?,
|
||||
shouldCapture: Bool,
|
||||
hasLocalDraggingSource: Bool,
|
||||
hasPaneTarget: Bool
|
||||
) {
|
||||
guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return }
|
||||
let signature = [
|
||||
shouldCapture ? "1" : "0",
|
||||
hasLocalDraggingSource ? "1" : "0",
|
||||
hasPaneTarget ? "1" : "0",
|
||||
debugPasteboardTypes(pasteboardTypes)
|
||||
].joined(separator: "|")
|
||||
guard lastDragRouteLogSignatureByPhase[phase] != signature else { return }
|
||||
lastDragRouteLogSignatureByPhase[phase] = signature
|
||||
cmuxDebugLog(
|
||||
"overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " +
|
||||
"localSource=\(hasLocalDraggingSource ? 1 : 0) " +
|
||||
"hasPane=\(hasPaneTarget ? 1 : 0) " +
|
||||
"types=\(debugPasteboardTypes(pasteboardTypes))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
/// Hit-tests the window to find the GhosttyNSView under the cursor.
|
||||
func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
|
||||
if let window,
|
||||
let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) {
|
||||
return portalTerminal
|
||||
}
|
||||
|
||||
guard let window, let contentView = window.contentView else { return nil }
|
||||
isHidden = true
|
||||
defer { isHidden = false }
|
||||
let point = contentView.convert(windowPoint, from: nil)
|
||||
let hitView = contentView.hitTest(point)
|
||||
|
||||
var current: NSView? = hitView
|
||||
while let view = current {
|
||||
if let terminal = view as? GhosttyNSView { return terminal }
|
||||
current = view.superview
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldDeferFileDropOverlayToBonsplitTabBar(at point: NSPoint) -> Bool {
|
||||
guard let window else { return false }
|
||||
let windowPoint = convert(point, to: nil)
|
||||
return BonsplitTabBarHitRegionRegistry.containsWindowPoint(windowPoint, in: window)
|
||||
}
|
||||
|
||||
func paneDropTargetUnderPoint(_ windowPoint: NSPoint) -> (any FileDropPaneTarget)? {
|
||||
if let paneTarget = inlinePaneDropTargetUnderPoint(windowPoint) {
|
||||
return paneTarget
|
||||
}
|
||||
guard let window else { return nil }
|
||||
if let terminalPaneTarget = TerminalWindowPortalRegistry.terminalPaneDropTargetAtWindowPoint(windowPoint, in: window) {
|
||||
return terminalPaneTarget
|
||||
}
|
||||
return BrowserWindowPortalRegistry.browserPaneDropTargetAtWindowPoint(windowPoint, in: window)
|
||||
}
|
||||
|
||||
func paneDropTargetForTextDrop(at windowPoint: NSPoint) -> (any FileDropPaneTarget)? {
|
||||
if let textView = editableTextViewUnderPoint(windowPoint),
|
||||
!(textView is SavingTextView) {
|
||||
return nil
|
||||
}
|
||||
return paneDropTargetUnderPoint(windowPoint)
|
||||
}
|
||||
|
||||
private func inlinePaneDropTargetUnderPoint(_ windowPoint: NSPoint) -> PaneDropTargetView? {
|
||||
guard let window, let contentView = window.contentView else { return nil }
|
||||
isHidden = true
|
||||
defer { isHidden = false }
|
||||
|
||||
let point = contentView.convert(windowPoint, from: nil)
|
||||
return paneDropTarget(in: contentView, at: point)
|
||||
}
|
||||
|
||||
private func paneDropTarget(in view: NSView, at point: NSPoint) -> PaneDropTargetView? {
|
||||
for subview in view.subviews.reversed() {
|
||||
guard !subview.isHidden, subview.alphaValue > 0 else { continue }
|
||||
let pointInSubview = subview.convert(point, from: view)
|
||||
guard subview.bounds.contains(pointInSubview) else { continue }
|
||||
if let paneTarget = subview as? PaneDropTargetView {
|
||||
return paneTarget
|
||||
}
|
||||
if let nestedTarget = paneDropTarget(in: subview, at: pointInSubview) {
|
||||
return nestedTarget
|
||||
}
|
||||
}
|
||||
return view as? PaneDropTargetView
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
struct FileSearchResult: Equatable {
|
||||
struct FileSearchResult: Equatable, Sendable {
|
||||
let path: String
|
||||
let relativePath: String
|
||||
let lineNumber: Int
|
||||
@@ -27,7 +28,7 @@ enum FileSearchRipgrepParser {
|
||||
let columnNumber = (firstStart ?? 0) + 1
|
||||
return FileSearchResult(
|
||||
path: path,
|
||||
relativePath: relativePath(for: path, rootPath: rootPath),
|
||||
relativePath: FileExplorerTerminalPathInsertion.relativePath(for: path, rootPath: rootPath),
|
||||
lineNumber: lineNumber,
|
||||
columnNumber: columnNumber,
|
||||
preview: lineText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -44,22 +45,10 @@ enum FileSearchRipgrepParser {
|
||||
}
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
private static func relativePath(for path: String, rootPath: String) -> String {
|
||||
guard !rootPath.isEmpty else { return path }
|
||||
let standardizedPath = URL(fileURLWithPath: path).standardizedFileURL.path
|
||||
let standardizedRoot = URL(fileURLWithPath: rootPath).standardizedFileURL.path
|
||||
guard standardizedPath.hasPrefix(standardizedRoot) else { return path }
|
||||
var relative = String(standardizedPath.dropFirst(standardizedRoot.count))
|
||||
if relative.hasPrefix("/") {
|
||||
relative.removeFirst()
|
||||
}
|
||||
return relative.isEmpty ? (path as NSString).lastPathComponent : relative
|
||||
}
|
||||
}
|
||||
|
||||
struct FileSearchSnapshot: Equatable {
|
||||
enum Status: Equatable {
|
||||
struct FileSearchSnapshot: Equatable, Sendable {
|
||||
enum Status: Equatable, Sendable {
|
||||
case idle
|
||||
case unsupported
|
||||
case searching
|
||||
@@ -78,7 +67,244 @@ struct FileSearchSnapshot: Equatable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FileSearchController {
|
||||
protocol FileSearchControlling: AnyObject {
|
||||
var onSnapshotChanged: ((FileSearchSnapshot) -> Void)? { get set }
|
||||
|
||||
func search(query rawQuery: String, rootPath: String, isLocal: Bool, contentRevision: Int)
|
||||
func cancel(clear: Bool)
|
||||
}
|
||||
|
||||
struct FileSearchPipelineUpdate: Sendable {
|
||||
let results: [FileSearchResult]
|
||||
let status: FileSearchSnapshot.Status
|
||||
let isSearching: Bool
|
||||
let shouldStopProcess: Bool
|
||||
}
|
||||
|
||||
private actor FileSearchTerminationSignal {
|
||||
private var status: Int32?
|
||||
private var continuations: [UUID: CheckedContinuation<Int32?, Never>] = [:]
|
||||
private var cancelledWaits = Set<UUID>()
|
||||
|
||||
func complete(status: Int32) {
|
||||
guard self.status == nil else { return }
|
||||
self.status = status
|
||||
let pendingContinuations = Array(continuations.values)
|
||||
continuations.removeAll()
|
||||
cancelledWaits.removeAll()
|
||||
for continuation in pendingContinuations {
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
|
||||
func wait() async -> Int32? {
|
||||
if let status {
|
||||
return status
|
||||
}
|
||||
let waitID = UUID()
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
if let status {
|
||||
continuation.resume(returning: status)
|
||||
} else if cancelledWaits.remove(waitID) != nil {
|
||||
continuation.resume(returning: nil)
|
||||
} else {
|
||||
continuations[waitID] = continuation
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
Task {
|
||||
await self.cancelWait(id: waitID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelWait(id: UUID) {
|
||||
guard status == nil else { return }
|
||||
if let continuation = continuations.removeValue(forKey: id) {
|
||||
continuation.resume(returning: nil)
|
||||
} else {
|
||||
cancelledWaits.insert(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor FileSearchOutputPipeline {
|
||||
private let rootPath: String
|
||||
private let maxResults: Int
|
||||
private let snapshotInterval: TimeInterval
|
||||
private var stdoutBuffer = Data()
|
||||
private var stderrBuffer = Data()
|
||||
private var results: [FileSearchResult] = []
|
||||
private var lastSnapshotEmissionDate = Date.distantPast
|
||||
private var isFinished = false
|
||||
private var terminalUpdate: FileSearchPipelineUpdate?
|
||||
|
||||
init(rootPath: String, maxResults: Int, snapshotInterval: TimeInterval) {
|
||||
self.rootPath = rootPath
|
||||
self.maxResults = maxResults
|
||||
self.snapshotInterval = snapshotInterval
|
||||
}
|
||||
|
||||
func consumeStdout(_ data: Data) -> FileSearchPipelineUpdate? {
|
||||
guard !isFinished else { return nil }
|
||||
stdoutBuffer.append(data)
|
||||
return consumeBufferedStdout(includeTrailingLine: false)
|
||||
}
|
||||
|
||||
private func consumeBufferedStdout(includeTrailingLine: Bool) -> FileSearchPipelineUpdate? {
|
||||
var latestUpdate: FileSearchPipelineUpdate?
|
||||
while let newlineIndex = stdoutBuffer.firstIndex(of: 10) {
|
||||
let lineData = stdoutBuffer[..<newlineIndex]
|
||||
stdoutBuffer.removeSubrange(...newlineIndex)
|
||||
guard let update = consumeStdoutLine(lineData) else { continue }
|
||||
latestUpdate = update
|
||||
if update.shouldStopProcess {
|
||||
return update
|
||||
}
|
||||
}
|
||||
|
||||
if includeTrailingLine, !stdoutBuffer.isEmpty {
|
||||
let lineData = stdoutBuffer
|
||||
stdoutBuffer.removeAll(keepingCapacity: true)
|
||||
if let update = consumeStdoutLine(lineData) {
|
||||
latestUpdate = update
|
||||
}
|
||||
}
|
||||
|
||||
return latestUpdate
|
||||
}
|
||||
|
||||
private func consumeStdoutLine(_ lineData: Data) -> FileSearchPipelineUpdate? {
|
||||
guard let line = String(data: lineData, encoding: .utf8),
|
||||
let result = FileSearchRipgrepParser.parseMatchLine(line, rootPath: rootPath) else {
|
||||
return nil
|
||||
}
|
||||
results.append(result)
|
||||
if results.count >= maxResults {
|
||||
let update = FileSearchPipelineUpdate(
|
||||
results: results,
|
||||
status: .limited(maxResults),
|
||||
isSearching: false,
|
||||
shouldStopProcess: true
|
||||
)
|
||||
isFinished = true
|
||||
terminalUpdate = update
|
||||
return update
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
guard now.timeIntervalSince(lastSnapshotEmissionDate) >= snapshotInterval else {
|
||||
return nil
|
||||
}
|
||||
lastSnapshotEmissionDate = now
|
||||
return FileSearchPipelineUpdate(
|
||||
results: results,
|
||||
status: .searching,
|
||||
isSearching: true,
|
||||
shouldStopProcess: false
|
||||
)
|
||||
}
|
||||
|
||||
func consumeStderr(_ data: Data) {
|
||||
guard !isFinished else { return }
|
||||
stderrBuffer.append(data)
|
||||
if stderrBuffer.count > 8_192 {
|
||||
stderrBuffer.removeSubrange(0..<(stderrBuffer.count - 8_192))
|
||||
}
|
||||
}
|
||||
|
||||
func consumeStderrLine(_ line: String) {
|
||||
guard !isFinished else { return }
|
||||
let lineData = Data((line + "\n").utf8)
|
||||
consumeStderr(lineData)
|
||||
}
|
||||
|
||||
func finish(status: Int32) -> FileSearchPipelineUpdate {
|
||||
if let terminalUpdate {
|
||||
return terminalUpdate
|
||||
}
|
||||
let trailingUpdate: FileSearchPipelineUpdate?
|
||||
if !isFinished {
|
||||
trailingUpdate = consumeBufferedStdout(includeTrailingLine: true)
|
||||
} else {
|
||||
trailingUpdate = nil
|
||||
}
|
||||
if let trailingUpdate, trailingUpdate.shouldStopProcess {
|
||||
return trailingUpdate
|
||||
}
|
||||
isFinished = true
|
||||
if status == 0 || status == 1 {
|
||||
return FileSearchPipelineUpdate(
|
||||
results: results,
|
||||
status: results.isEmpty ? .noMatches : .matches,
|
||||
isSearching: false,
|
||||
shouldStopProcess: false
|
||||
)
|
||||
}
|
||||
|
||||
let errorText = String(data: stderrBuffer, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallback = String(
|
||||
format: String(localized: "fileExplorer.search.rgExited", defaultValue: "rg exited with status %d"),
|
||||
Int(status)
|
||||
)
|
||||
return FileSearchPipelineUpdate(
|
||||
results: results,
|
||||
status: .failed(errorText?.isEmpty == false ? errorText! : fallback),
|
||||
isSearching: false,
|
||||
shouldStopProcess: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FileSearchReadHandle: @unchecked Sendable {
|
||||
private let fileHandle: FileHandle
|
||||
|
||||
init(_ fileHandle: FileHandle) {
|
||||
self.fileHandle = fileHandle
|
||||
}
|
||||
|
||||
var fileDescriptor: Int32 {
|
||||
fileHandle.fileDescriptor
|
||||
}
|
||||
}
|
||||
|
||||
private enum FileSearchPipeReadResult: Sendable {
|
||||
case chunk(Data)
|
||||
case endOfFile
|
||||
case failure(Int32)
|
||||
}
|
||||
|
||||
private enum FileSearchPipeReader {
|
||||
private static let queue = DispatchQueue(
|
||||
label: "com.cmux.file-search.pipe-read",
|
||||
qos: .userInitiated,
|
||||
attributes: .concurrent
|
||||
)
|
||||
|
||||
static func read(from readHandle: FileSearchReadHandle, maxByteCount: Int) async -> FileSearchPipeReadResult {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async {
|
||||
var buffer = [UInt8](repeating: 0, count: maxByteCount)
|
||||
let bytesRead = buffer.withUnsafeMutableBytes { rawBuffer in
|
||||
// Keep blocking pipe reads off Swift's cooperative executor.
|
||||
Darwin.read(readHandle.fileDescriptor, rawBuffer.baseAddress, rawBuffer.count)
|
||||
}
|
||||
if bytesRead > 0 {
|
||||
continuation.resume(returning: .chunk(Data(buffer.prefix(bytesRead))))
|
||||
} else if bytesRead == 0 {
|
||||
continuation.resume(returning: .endOfFile)
|
||||
} else {
|
||||
continuation.resume(returning: .failure(errno))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FileSearchController: FileSearchControlling {
|
||||
private struct Request: Equatable {
|
||||
let query: String
|
||||
let rootPath: String
|
||||
@@ -94,6 +320,7 @@ final class FileSearchController {
|
||||
var onSnapshotChanged: ((FileSearchSnapshot) -> Void)?
|
||||
|
||||
private let maxResults = 500
|
||||
private let snapshotInterval: TimeInterval = 0.05
|
||||
private let excludedSearchGlobs = [
|
||||
"!.git/**",
|
||||
"!**/.git/**",
|
||||
@@ -107,11 +334,11 @@ final class FileSearchController {
|
||||
"!**/DerivedData/**",
|
||||
]
|
||||
private var process: Process?
|
||||
private var stdoutBuffer = Data()
|
||||
private var stderrBuffer = Data()
|
||||
private var generation = 0
|
||||
private var request: Request?
|
||||
private var results: [FileSearchResult] = []
|
||||
private var pipeline: FileSearchOutputPipeline?
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
func search(query rawQuery: String, rootPath: String, isLocal: Bool, contentRevision: Int = 0) {
|
||||
let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -128,8 +355,6 @@ final class FileSearchController {
|
||||
|
||||
stopAndAdvanceGeneration()
|
||||
results.removeAll()
|
||||
stdoutBuffer.removeAll(keepingCapacity: true)
|
||||
stderrBuffer.removeAll(keepingCapacity: true)
|
||||
|
||||
guard !query.isEmpty else {
|
||||
emit(status: .idle, isSearching: false)
|
||||
@@ -177,34 +402,60 @@ final class FileSearchController {
|
||||
let stderr = Pipe()
|
||||
process.standardOutput = stdout
|
||||
process.standardError = stderr
|
||||
let pipeline = FileSearchOutputPipeline(
|
||||
rootPath: rootPath,
|
||||
maxResults: maxResults,
|
||||
snapshotInterval: snapshotInterval
|
||||
)
|
||||
self.pipeline = pipeline
|
||||
let terminationSignal = FileSearchTerminationSignal()
|
||||
|
||||
stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
self?.consumeStdout(data, generation: searchGeneration, rootPath: rootPath)
|
||||
}
|
||||
}
|
||||
stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
self?.consumeStderr(data, generation: searchGeneration)
|
||||
}
|
||||
}
|
||||
|
||||
process.terminationHandler = { [weak self] process in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.finish(generation: searchGeneration, status: process.terminationStatus)
|
||||
process.terminationHandler = { process in
|
||||
let status = process.terminationStatus
|
||||
Task {
|
||||
await terminationSignal.complete(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
self.process = process
|
||||
let stdoutReadHandle = FileSearchReadHandle(stdout.fileHandleForReading)
|
||||
let stderrReadHandle = FileSearchReadHandle(stderr.fileHandleForReading)
|
||||
searchTask = Task.detached(priority: .userInitiated) { [weak self, pipeline, terminationSignal, stdoutReadHandle, stderrReadHandle] in
|
||||
// Result completeness is defined by stdout. Stderr stays diagnostic-only:
|
||||
// successful searches do not wait on it, failed searches do before formatting the error.
|
||||
let stderrTask = Task.detached(priority: .utility) { [stderrReadHandle, pipeline] in
|
||||
await Self.streamStderr(from: stderrReadHandle, pipeline: pipeline)
|
||||
}
|
||||
let applyUpdate: @Sendable (FileSearchPipelineUpdate, Int) async -> Void = { [weak self] update, generation in
|
||||
await self?.applyPipelineUpdate(update, generation: generation)
|
||||
}
|
||||
let stdoutTask = Task.detached(priority: .userInitiated) { [stdoutReadHandle, pipeline, searchGeneration, applyUpdate] in
|
||||
await Self.streamStdout(
|
||||
from: stdoutReadHandle,
|
||||
pipeline: pipeline,
|
||||
generation: searchGeneration,
|
||||
applyUpdate: applyUpdate
|
||||
)
|
||||
}
|
||||
defer {
|
||||
stderrTask.cancel()
|
||||
stdoutTask.cancel()
|
||||
}
|
||||
guard let status = await terminationSignal.wait() else { return }
|
||||
await stdoutTask.value
|
||||
guard !Task.isCancelled else { return }
|
||||
if status != 0 && status != 1 {
|
||||
await stderrTask.value
|
||||
}
|
||||
let update = await pipeline.finish(status: status)
|
||||
await self?.finish(generation: searchGeneration, update: update)
|
||||
}
|
||||
} catch {
|
||||
process.standardOutput = nil
|
||||
process.standardError = nil
|
||||
self.pipeline = nil
|
||||
emit(status: .failed(error.localizedDescription), isSearching: false)
|
||||
}
|
||||
}
|
||||
@@ -212,64 +463,28 @@ final class FileSearchController {
|
||||
func cancel(clear: Bool) {
|
||||
request = nil
|
||||
stopAndAdvanceGeneration()
|
||||
stdoutBuffer.removeAll(keepingCapacity: true)
|
||||
stderrBuffer.removeAll(keepingCapacity: true)
|
||||
if clear {
|
||||
results.removeAll()
|
||||
emit(status: .idle, isSearching: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func consumeStdout(_ data: Data, generation searchGeneration: Int, rootPath: String) {
|
||||
private func applyPipelineUpdate(_ update: FileSearchPipelineUpdate, generation searchGeneration: Int) {
|
||||
guard searchGeneration == generation else { return }
|
||||
stdoutBuffer.append(data)
|
||||
var didAppendResult = false
|
||||
|
||||
while let newlineIndex = stdoutBuffer.firstIndex(of: 10) {
|
||||
let lineData = stdoutBuffer[..<newlineIndex]
|
||||
stdoutBuffer.removeSubrange(...newlineIndex)
|
||||
guard let line = String(data: lineData, encoding: .utf8),
|
||||
let result = FileSearchRipgrepParser.parseMatchLine(line, rootPath: rootPath) else {
|
||||
continue
|
||||
}
|
||||
results.append(result)
|
||||
didAppendResult = true
|
||||
if results.count >= maxResults {
|
||||
stopAndAdvanceGeneration()
|
||||
emit(status: .limited(maxResults), isSearching: false)
|
||||
return
|
||||
}
|
||||
}
|
||||
if didAppendResult {
|
||||
emit(status: .searching, isSearching: true)
|
||||
results = update.results
|
||||
if update.shouldStopProcess {
|
||||
stopAndAdvanceGeneration()
|
||||
}
|
||||
emit(status: update.status, isSearching: update.isSearching)
|
||||
}
|
||||
|
||||
private func consumeStderr(_ data: Data, generation searchGeneration: Int) {
|
||||
private func finish(generation searchGeneration: Int, update: FileSearchPipelineUpdate) {
|
||||
guard searchGeneration == generation else { return }
|
||||
stderrBuffer.append(data)
|
||||
if stderrBuffer.count > 8_192 {
|
||||
stderrBuffer.removeSubrange(0..<(stderrBuffer.count - 8_192))
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(generation searchGeneration: Int, status: Int32) {
|
||||
guard searchGeneration == generation else { return }
|
||||
stopCurrentProcess()
|
||||
|
||||
if status == 0 || status == 1 {
|
||||
let finalStatus: FileSearchSnapshot.Status = results.isEmpty ? .noMatches : .matches
|
||||
emit(status: finalStatus, isSearching: false)
|
||||
return
|
||||
}
|
||||
|
||||
let errorText = String(data: stderrBuffer, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallback = String(
|
||||
format: String(localized: "fileExplorer.search.rgExited", defaultValue: "rg exited with status %d"),
|
||||
Int(status)
|
||||
)
|
||||
emit(status: .failed(errorText?.isEmpty == false ? errorText! : fallback), isSearching: false)
|
||||
process = nil
|
||||
pipeline = nil
|
||||
searchTask = nil
|
||||
results = update.results
|
||||
emit(status: update.status, isSearching: update.isSearching)
|
||||
}
|
||||
|
||||
private func emit(status: FileSearchSnapshot.Status, isSearching: Bool) {
|
||||
@@ -289,11 +504,57 @@ final class FileSearchController {
|
||||
private func stopCurrentProcess() {
|
||||
guard let process else { return }
|
||||
self.process = nil
|
||||
(process.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil
|
||||
(process.standardError as? Pipe)?.fileHandleForReading.readabilityHandler = nil
|
||||
process.terminationHandler = nil
|
||||
searchTask?.cancel()
|
||||
searchTask = nil
|
||||
pipeline = nil
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
_ = Darwin.kill(process.processIdentifier, SIGTERM)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func streamStdout(
|
||||
from readHandle: FileSearchReadHandle,
|
||||
pipeline: FileSearchOutputPipeline,
|
||||
generation: Int,
|
||||
applyUpdate: @Sendable (FileSearchPipelineUpdate, Int) async -> Void
|
||||
) async {
|
||||
while !Task.isCancelled {
|
||||
let readResult = await FileSearchPipeReader.read(from: readHandle, maxByteCount: 32 * 1024)
|
||||
guard !Task.isCancelled else { return }
|
||||
switch readResult {
|
||||
case .chunk(let data):
|
||||
guard let update = await pipeline.consumeStdout(data) else { continue }
|
||||
await applyUpdate(update, generation)
|
||||
if update.shouldStopProcess { return }
|
||||
case .endOfFile:
|
||||
return
|
||||
case .failure(let errorNumber) where errorNumber == EINTR:
|
||||
continue
|
||||
case .failure(let errorNumber):
|
||||
await pipeline.consumeStderrLine(String(cString: strerror(errorNumber)))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func streamStderr(
|
||||
from readHandle: FileSearchReadHandle,
|
||||
pipeline: FileSearchOutputPipeline
|
||||
) async {
|
||||
while !Task.isCancelled {
|
||||
let readResult = await FileSearchPipeReader.read(from: readHandle, maxByteCount: 8 * 1024)
|
||||
guard !Task.isCancelled else { return }
|
||||
switch readResult {
|
||||
case .chunk(let data):
|
||||
await pipeline.consumeStderr(data)
|
||||
case .endOfFile:
|
||||
return
|
||||
case .failure(let errorNumber) where errorNumber == EINTR:
|
||||
continue
|
||||
case .failure(let errorNumber):
|
||||
await pipeline.consumeStderrLine(String(cString: strerror(errorNumber)))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+436
-71
@@ -203,7 +203,7 @@ enum FileExplorerStyle: Int, CaseIterable {
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct FileExplorerEntry {
|
||||
struct FileExplorerEntry: Sendable {
|
||||
let name: String
|
||||
let path: String
|
||||
let isDirectory: Bool
|
||||
@@ -261,6 +261,34 @@ protocol FileExplorerProvider: AnyObject {
|
||||
var isAvailable: Bool { get }
|
||||
}
|
||||
|
||||
struct SSHFileExplorerConnection: Equatable, Sendable {
|
||||
let destination: String
|
||||
let port: Int?
|
||||
let identityFile: String?
|
||||
let sshOptions: [String]
|
||||
}
|
||||
|
||||
protocol SSHFileExplorerTransport: AnyObject {
|
||||
nonisolated func resolveHomePath(connection: SSHFileExplorerConnection) async throws -> String
|
||||
nonisolated func listDirectory(
|
||||
path: String,
|
||||
connection: SSHFileExplorerConnection,
|
||||
showHidden: Bool
|
||||
) async throws -> [FileExplorerEntry]
|
||||
}
|
||||
|
||||
enum FileExplorerWorkspaceRoot: Equatable {
|
||||
case none
|
||||
case local(path: String)
|
||||
case remoteSSH(
|
||||
workspaceId: UUID,
|
||||
connection: SSHFileExplorerConnection,
|
||||
displayTarget: String,
|
||||
isAvailable: Bool,
|
||||
unavailableDetail: String?
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Local Provider
|
||||
|
||||
final class LocalFileExplorerProvider: FileExplorerProvider {
|
||||
@@ -282,107 +310,229 @@ final class LocalFileExplorerProvider: FileExplorerProvider {
|
||||
|
||||
// MARK: - SSH Provider
|
||||
|
||||
final class SSHFileExplorerProvider: FileExplorerProvider {
|
||||
let destination: String
|
||||
let port: Int?
|
||||
let identityFile: String?
|
||||
let sshOptions: [String]
|
||||
private(set) var homePath: String
|
||||
private(set) var isAvailable: Bool
|
||||
// Captured by async SSH tasks; mutable availability/root state is guarded by stateLock.
|
||||
final class SSHFileExplorerProvider: FileExplorerProvider, @unchecked Sendable {
|
||||
private struct State: Sendable {
|
||||
var homePath: String
|
||||
var isAvailable: Bool
|
||||
}
|
||||
|
||||
let connection: SSHFileExplorerConnection
|
||||
let displayTarget: String
|
||||
private let transport: SSHFileExplorerTransport
|
||||
private let stateLock = NSLock()
|
||||
private var state: State
|
||||
|
||||
var homePath: String {
|
||||
stateLock.lock()
|
||||
defer { stateLock.unlock() }
|
||||
return state.homePath
|
||||
}
|
||||
|
||||
var isAvailable: Bool {
|
||||
stateLock.lock()
|
||||
defer { stateLock.unlock() }
|
||||
return state.isAvailable
|
||||
}
|
||||
|
||||
var destination: String { connection.destination }
|
||||
var port: Int? { connection.port }
|
||||
var identityFile: String? { connection.identityFile }
|
||||
var sshOptions: [String] { connection.sshOptions }
|
||||
|
||||
init(
|
||||
destination: String,
|
||||
port: Int?,
|
||||
identityFile: String?,
|
||||
sshOptions: [String],
|
||||
displayTarget: String? = nil,
|
||||
homePath: String,
|
||||
isAvailable: Bool
|
||||
isAvailable: Bool,
|
||||
transport: SSHFileExplorerTransport = ProcessSSHFileExplorerTransport.shared
|
||||
) {
|
||||
self.destination = destination
|
||||
self.port = port
|
||||
self.identityFile = identityFile
|
||||
self.sshOptions = sshOptions
|
||||
self.homePath = homePath
|
||||
self.isAvailable = isAvailable
|
||||
self.connection = SSHFileExplorerConnection(
|
||||
destination: destination,
|
||||
port: port,
|
||||
identityFile: identityFile,
|
||||
sshOptions: sshOptions
|
||||
)
|
||||
self.displayTarget = displayTarget ?? {
|
||||
guard let port else { return destination }
|
||||
return "\(destination):\(port)"
|
||||
}()
|
||||
self.transport = transport
|
||||
self.state = State(homePath: homePath, isAvailable: isAvailable)
|
||||
}
|
||||
|
||||
init(
|
||||
connection: SSHFileExplorerConnection,
|
||||
displayTarget: String,
|
||||
homePath: String,
|
||||
isAvailable: Bool,
|
||||
transport: SSHFileExplorerTransport = ProcessSSHFileExplorerTransport.shared
|
||||
) {
|
||||
self.connection = connection
|
||||
self.displayTarget = displayTarget
|
||||
self.transport = transport
|
||||
self.state = State(homePath: homePath, isAvailable: isAvailable)
|
||||
}
|
||||
|
||||
func updateAvailability(_ available: Bool, homePath: String?) {
|
||||
self.isAvailable = available
|
||||
stateLock.lock()
|
||||
defer { stateLock.unlock() }
|
||||
state.isAvailable = available
|
||||
if let homePath {
|
||||
self.homePath = homePath
|
||||
state.homePath = homePath
|
||||
}
|
||||
}
|
||||
|
||||
func resolveHomePath() async throws -> String {
|
||||
guard isAvailable else {
|
||||
throw FileExplorerError.providerUnavailable
|
||||
}
|
||||
let home = try await transport.resolveHomePath(connection: connection)
|
||||
guard !home.isEmpty else {
|
||||
throw FileExplorerError.sshCommandFailed("remote HOME was empty")
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry] {
|
||||
guard isAvailable else {
|
||||
throw FileExplorerError.providerUnavailable
|
||||
}
|
||||
// Capture immutable config values for Sendable closure
|
||||
let dest = destination
|
||||
let p = port
|
||||
let identity = identityFile
|
||||
let opts = sshOptions
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
let result = try SSHFileExplorerProvider.runSSHListCommand(
|
||||
path: path, destination: dest, port: p,
|
||||
identityFile: identity, sshOptions: opts,
|
||||
showHidden: showHidden
|
||||
)
|
||||
continuation.resume(returning: result)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
return try await transport.listDirectory(path: path, connection: connection, showHidden: showHidden)
|
||||
}
|
||||
}
|
||||
|
||||
final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport {
|
||||
static let shared = ProcessSSHFileExplorerTransport()
|
||||
|
||||
nonisolated func resolveHomePath(connection: SSHFileExplorerConnection) async throws -> String {
|
||||
let output = try await Self.runSSHCommand(
|
||||
connection: connection,
|
||||
command: #"printf '%s\n' "$HOME""#
|
||||
)
|
||||
return output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
nonisolated func listDirectory(
|
||||
path: String,
|
||||
connection: SSHFileExplorerConnection,
|
||||
showHidden: Bool
|
||||
) async throws -> [FileExplorerEntry] {
|
||||
try await Self.runSSHListCommand(path: path, connection: connection, showHidden: showHidden)
|
||||
}
|
||||
|
||||
private struct SSHCommandResult: Sendable {
|
||||
let stdout: String
|
||||
let stderr: String
|
||||
let terminationStatus: Int32
|
||||
}
|
||||
|
||||
// Keeps the child process reachable from the cancellation handler while
|
||||
// the blocking wait runs off Swift's cooperative executor.
|
||||
private final class SSHCommandProcess: @unchecked Sendable {
|
||||
private let process = Process()
|
||||
private let outPipe = Pipe()
|
||||
private let errPipe = Pipe()
|
||||
private let lock = NSLock()
|
||||
private var cancelled = false
|
||||
|
||||
init(connection: SSHFileExplorerConnection, command: String) {
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
process.arguments = ProcessSSHFileExplorerTransport.sshArguments(connection: connection, command: command)
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
}
|
||||
|
||||
func run() throws -> SSHCommandResult {
|
||||
lock.lock()
|
||||
let wasCancelled = cancelled
|
||||
lock.unlock()
|
||||
if wasCancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
try process.run()
|
||||
|
||||
lock.lock()
|
||||
let shouldTerminate = cancelled && process.isRunning
|
||||
lock.unlock()
|
||||
if shouldTerminate {
|
||||
process.terminate()
|
||||
}
|
||||
|
||||
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let stderrData = errPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
return SSHCommandResult(
|
||||
stdout: String(data: data, encoding: .utf8) ?? "",
|
||||
stderr: String(data: stderrData, encoding: .utf8) ?? "",
|
||||
terminationStatus: process.terminationStatus
|
||||
)
|
||||
}
|
||||
|
||||
func terminate() {
|
||||
lock.lock()
|
||||
cancelled = true
|
||||
let isRunning = process.isRunning
|
||||
lock.unlock()
|
||||
|
||||
if isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func runSSHListCommand(
|
||||
path: String, destination: String, port: Int?,
|
||||
identityFile: String?, sshOptions: [String],
|
||||
showHidden: Bool
|
||||
) throws -> [FileExplorerEntry] {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
private static func runSSHCommand(connection: SSHFileExplorerConnection, command: String) async throws -> String {
|
||||
let commandProcess = SSHCommandProcess(connection: connection, command: command)
|
||||
let result = try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
continuation.resume(with: Result { try commandProcess.run() })
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
commandProcess.terminate()
|
||||
}
|
||||
|
||||
guard result.terminationStatus == 0 else {
|
||||
throw FileExplorerError.sshCommandFailed(result.stderr)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
private static func sshArguments(connection: SSHFileExplorerConnection, command: String) -> [String] {
|
||||
var args: [String] = []
|
||||
if let port {
|
||||
if let port = connection.port {
|
||||
args += ["-p", String(port)]
|
||||
}
|
||||
if let identityFile {
|
||||
if let identityFile = connection.identityFile {
|
||||
args += ["-i", identityFile]
|
||||
}
|
||||
for option in sshOptions {
|
||||
for option in connection.sshOptions {
|
||||
args += ["-o", option]
|
||||
}
|
||||
// Batch mode, no TTY, connection timeout
|
||||
args += ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-T"]
|
||||
args += [connection.destination, command]
|
||||
return args
|
||||
}
|
||||
|
||||
private static func runSSHListCommand(
|
||||
path: String,
|
||||
connection: SSHFileExplorerConnection,
|
||||
showHidden: Bool
|
||||
) async throws -> [FileExplorerEntry] {
|
||||
// Escape single quotes in path for shell safety
|
||||
let escapedPath = path.replacingOccurrences(of: "'", with: "'\\''")
|
||||
let lsFlags = showHidden ? "-1paFA" : "-1paF"
|
||||
args += [destination, "ls \(lsFlags) '\(escapedPath)' 2>/dev/null"]
|
||||
|
||||
process.arguments = args
|
||||
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
|
||||
try process.run()
|
||||
// Read pipe data before waitUntilExit to avoid deadlock when pipe buffer fills
|
||||
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let stderrData = errPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
let stderrStr = String(data: stderrData, encoding: .utf8) ?? ""
|
||||
throw FileExplorerError.sshCommandFailed(stderrStr)
|
||||
}
|
||||
guard let output = String(data: data, encoding: .utf8) else {
|
||||
return []
|
||||
}
|
||||
let output = try await runSSHCommand(
|
||||
connection: connection,
|
||||
command: "ls \(lsFlags) '\(escapedPath)' 2>/dev/null"
|
||||
)
|
||||
|
||||
let normalizedPath = path.hasSuffix("/") ? path : path + "/"
|
||||
return output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in
|
||||
@@ -419,6 +569,17 @@ enum FileExplorerError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection Restoration
|
||||
|
||||
enum FileExplorerSelectionRestoration {
|
||||
static func scrollRow(anchorRow: Int?, exactRows: IndexSet) -> Int? {
|
||||
if let anchorRow, exactRows.contains(anchorRow) {
|
||||
return anchorRow
|
||||
}
|
||||
return exactRows.first
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Store
|
||||
|
||||
/// All access must happen on the main thread. Properties are not marked @MainActor
|
||||
@@ -430,6 +591,7 @@ final class FileExplorerStore: ObservableObject {
|
||||
@Published private(set) var isRootLoading: Bool = false
|
||||
@Published private(set) var gitStatusByPath: [String: GitFileStatus] = [:]
|
||||
@Published private(set) var contentRevision = 0
|
||||
@Published private(set) var rootStatusMessage: String?
|
||||
|
||||
var provider: FileExplorerProvider?
|
||||
|
||||
@@ -445,6 +607,9 @@ final class FileExplorerStore: ObservableObject {
|
||||
/// Stable navigation selection. The outline view mirrors this path after reloads.
|
||||
private(set) var selectedPath: String?
|
||||
|
||||
/// Stable multi-selection. `selectedPath` remains the keyboard/navigation anchor.
|
||||
private(set) var selectedPaths: Set<String> = []
|
||||
|
||||
/// Folder path whose first child should be selected once its async load completes.
|
||||
private var pendingDescendIntoFirstChildPath: String?
|
||||
|
||||
@@ -460,12 +625,55 @@ final class FileExplorerStore: ObservableObject {
|
||||
/// Prefetch debounce: path -> work item
|
||||
private var prefetchWorkItems: [String: DispatchWorkItem] = [:]
|
||||
|
||||
private var remoteHomeResolutionTask: Task<Void, Never>?
|
||||
private var remoteHomeResolutionKey: String?
|
||||
|
||||
var displayRootPath: String {
|
||||
FileExplorerRootResolver.displayPath(for: rootPath, homePath: provider?.homePath)
|
||||
if let sshProvider = provider as? SSHFileExplorerProvider {
|
||||
guard !rootPath.isEmpty else {
|
||||
return "ssh://\(sshProvider.displayTarget)"
|
||||
}
|
||||
return "ssh://\(sshProvider.displayTarget):\(rootPath)"
|
||||
}
|
||||
return FileExplorerRootResolver.displayPath(for: rootPath, homePath: provider?.homePath)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
func applyWorkspaceRoot(
|
||||
_ request: FileExplorerWorkspaceRoot,
|
||||
sshTransport: SSHFileExplorerTransport = ProcessSSHFileExplorerTransport.shared
|
||||
) {
|
||||
switch request {
|
||||
case .none:
|
||||
cancelRemoteHomeResolution()
|
||||
setRootStatusMessage(nil)
|
||||
if provider != nil {
|
||||
setProvider(nil, reloadIfAvailable: false)
|
||||
}
|
||||
setRootPath("")
|
||||
|
||||
case .local(let path):
|
||||
cancelRemoteHomeResolution()
|
||||
setRootStatusMessage(nil)
|
||||
if !(provider is LocalFileExplorerProvider) {
|
||||
setRootPath("")
|
||||
setProvider(LocalFileExplorerProvider(), reloadIfAvailable: false)
|
||||
}
|
||||
setRootPath(path)
|
||||
|
||||
case .remoteSSH(let workspaceId, let connection, let displayTarget, let isAvailable, let unavailableDetail):
|
||||
applyRemoteSSHWorkspaceRoot(
|
||||
workspaceId: workspaceId,
|
||||
connection: connection,
|
||||
displayTarget: displayTarget,
|
||||
isAvailable: isAvailable,
|
||||
unavailableDetail: unavailableDetail,
|
||||
sshTransport: sshTransport
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func setRootPath(_ path: String) {
|
||||
guard path != rootPath else {
|
||||
#if DEBUG
|
||||
@@ -478,6 +686,7 @@ final class FileExplorerStore: ObservableObject {
|
||||
#endif
|
||||
if let selectedPath, !Self.path(selectedPath, isContainedIn: path) {
|
||||
self.selectedPath = nil
|
||||
selectedPaths = []
|
||||
pendingDescendIntoFirstChildPath = nil
|
||||
}
|
||||
rootPath = path
|
||||
@@ -530,17 +739,23 @@ final class FileExplorerStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func setProvider(_ newProvider: FileExplorerProvider?) {
|
||||
private func setProvider(_ newProvider: FileExplorerProvider?, reloadIfAvailable: Bool = true) {
|
||||
#if DEBUG
|
||||
NSLog("[FileExplorer] setProvider: \(type(of: newProvider).self) available=\(newProvider?.isAvailable ?? false)")
|
||||
#endif
|
||||
provider = newProvider
|
||||
// Re-expand previously expanded nodes if provider becomes available
|
||||
if newProvider?.isAvailable == true {
|
||||
if reloadIfAvailable, newProvider?.isAvailable == true {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setProviderForTesting(_ newProvider: FileExplorerProvider?, reloadIfAvailable: Bool = true) {
|
||||
setProvider(newProvider, reloadIfAvailable: reloadIfAvailable)
|
||||
}
|
||||
#endif
|
||||
|
||||
func reload() {
|
||||
#if DEBUG
|
||||
NSLog("[FileExplorer] reload() path=\(rootPath) provider=\(type(of: provider).self)")
|
||||
@@ -589,8 +804,21 @@ final class FileExplorerStore: ObservableObject {
|
||||
|
||||
func select(node: FileExplorerNode?) {
|
||||
let path = node?.path
|
||||
guard selectedPath != path else { return }
|
||||
let paths = path.map { Set([$0]) } ?? []
|
||||
guard selectedPath != path || selectedPaths != paths else { return }
|
||||
selectedPath = path
|
||||
selectedPaths = paths
|
||||
if path != pendingDescendIntoFirstChildPath {
|
||||
pendingDescendIntoFirstChildPath = nil
|
||||
}
|
||||
}
|
||||
|
||||
func select(nodes: [FileExplorerNode], anchor: FileExplorerNode?) {
|
||||
let paths = Set(nodes.map(\.path))
|
||||
let path = anchor?.path ?? nodes.first?.path
|
||||
guard selectedPath != path || selectedPaths != paths else { return }
|
||||
selectedPath = path
|
||||
selectedPaths = paths
|
||||
if path != pendingDescendIntoFirstChildPath {
|
||||
pendingDescendIntoFirstChildPath = nil
|
||||
}
|
||||
@@ -599,6 +827,7 @@ final class FileExplorerStore: ObservableObject {
|
||||
func requestDescendIntoFirstChild(of node: FileExplorerNode) {
|
||||
guard node.isDirectory else { return }
|
||||
selectedPath = node.path
|
||||
selectedPaths = [node.path]
|
||||
pendingDescendIntoFirstChildPath = node.path
|
||||
expand(node: node)
|
||||
}
|
||||
@@ -648,6 +877,7 @@ final class FileExplorerStore: ObservableObject {
|
||||
|
||||
do {
|
||||
let entries = try await provider.listDirectory(path: path, showHidden: showHiddenFiles)
|
||||
try Task.checkCancellation()
|
||||
let children = entries.map { entry in
|
||||
let node = FileExplorerNode(name: entry.name, path: entry.path, isDirectory: entry.isDirectory)
|
||||
nodesByPath[entry.path] = node
|
||||
@@ -662,14 +892,18 @@ final class FileExplorerStore: ObservableObject {
|
||||
parentNode.isLoading = false
|
||||
parentNode.error = nil
|
||||
if pendingDescendIntoFirstChildPath == parentNode.path {
|
||||
selectedPath = children.first?.path ?? parentNode.path
|
||||
let path = children.first?.path ?? parentNode.path
|
||||
selectedPath = path
|
||||
selectedPaths = [path]
|
||||
pendingDescendIntoFirstChildPath = nil
|
||||
}
|
||||
} else {
|
||||
rootNodes = children
|
||||
isRootLoading = false
|
||||
setRootStatusMessage(nil)
|
||||
if selectedPath == nil {
|
||||
selectedPath = children.first?.path
|
||||
selectedPaths = selectedPath.map { Set([$0]) } ?? []
|
||||
}
|
||||
}
|
||||
loadingPaths.remove(path)
|
||||
@@ -694,6 +928,7 @@ final class FileExplorerStore: ObservableObject {
|
||||
parentNode.error = error.localizedDescription
|
||||
} else {
|
||||
isRootLoading = false
|
||||
setRootStatusMessage(error.localizedDescription)
|
||||
}
|
||||
loadingPaths.remove(path)
|
||||
loadTasks.removeValue(forKey: path)
|
||||
@@ -716,6 +951,132 @@ final class FileExplorerStore: ObservableObject {
|
||||
isRootLoading = false
|
||||
}
|
||||
|
||||
private func applyRemoteSSHWorkspaceRoot(
|
||||
workspaceId: UUID,
|
||||
connection: SSHFileExplorerConnection,
|
||||
displayTarget: String,
|
||||
isAvailable: Bool,
|
||||
unavailableDetail: String?,
|
||||
sshTransport: SSHFileExplorerTransport
|
||||
) {
|
||||
let existingProvider = provider as? SSHFileExplorerProvider
|
||||
let sshProvider: SSHFileExplorerProvider
|
||||
if let existingProvider,
|
||||
existingProvider.connection == connection,
|
||||
existingProvider.displayTarget == displayTarget {
|
||||
sshProvider = existingProvider
|
||||
sshProvider.updateAvailability(isAvailable, homePath: nil)
|
||||
} else {
|
||||
cancelRemoteHomeResolution()
|
||||
setRootPath("")
|
||||
sshProvider = SSHFileExplorerProvider(
|
||||
connection: connection,
|
||||
displayTarget: displayTarget,
|
||||
homePath: "",
|
||||
isAvailable: isAvailable,
|
||||
transport: sshTransport
|
||||
)
|
||||
setProvider(sshProvider, reloadIfAvailable: false)
|
||||
}
|
||||
|
||||
guard isAvailable else {
|
||||
cancelRemoteHomeResolution()
|
||||
setRootPath("")
|
||||
let detail = unavailableDetail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let detail, !detail.isEmpty {
|
||||
setRootStatusMessage(
|
||||
String(
|
||||
localized: "fileExplorer.status.sshUnavailableWithDetail",
|
||||
defaultValue: "SSH files unavailable: \(detail)"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setRootStatusMessage(
|
||||
String(localized: "fileExplorer.status.sshUnavailable", defaultValue: "SSH files unavailable")
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let currentHomePath = sshProvider.homePath
|
||||
if !currentHomePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
setRootStatusMessage(nil)
|
||||
setRootPath(currentHomePath)
|
||||
return
|
||||
}
|
||||
|
||||
resolveRemoteHome(
|
||||
workspaceId: workspaceId,
|
||||
provider: sshProvider,
|
||||
connection: connection
|
||||
)
|
||||
}
|
||||
|
||||
private func resolveRemoteHome(
|
||||
workspaceId: UUID,
|
||||
provider sshProvider: SSHFileExplorerProvider,
|
||||
connection: SSHFileExplorerConnection
|
||||
) {
|
||||
let resolutionKey = [
|
||||
workspaceId.uuidString,
|
||||
connection.destination,
|
||||
connection.port.map(String.init) ?? "",
|
||||
connection.identityFile ?? "",
|
||||
connection.sshOptions.joined(separator: "\u{1f}"),
|
||||
].joined(separator: "\u{1e}")
|
||||
|
||||
guard remoteHomeResolutionKey != resolutionKey else { return }
|
||||
remoteHomeResolutionTask?.cancel()
|
||||
remoteHomeResolutionKey = resolutionKey
|
||||
setRootPath("")
|
||||
setRootStatusMessage(String(localized: "fileExplorer.status.sshResolvingHome", defaultValue: "Resolving remote home..."))
|
||||
|
||||
remoteHomeResolutionTask = Task { [weak self, weak sshProvider] in
|
||||
guard let sshProvider else { return }
|
||||
do {
|
||||
let homePath = try await sshProvider.resolveHomePath()
|
||||
await MainActor.run { [weak self, weak sshProvider] in
|
||||
guard let self,
|
||||
let sshProvider,
|
||||
self.remoteHomeResolutionKey == resolutionKey,
|
||||
self.provider === sshProvider else { return }
|
||||
self.remoteHomeResolutionKey = nil
|
||||
self.remoteHomeResolutionTask = nil
|
||||
sshProvider.updateAvailability(true, homePath: homePath)
|
||||
self.setRootStatusMessage(nil)
|
||||
self.setRootPath(homePath)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { [weak self, weak sshProvider] in
|
||||
guard let self,
|
||||
let sshProvider,
|
||||
self.remoteHomeResolutionKey == resolutionKey,
|
||||
self.provider === sshProvider else { return }
|
||||
self.remoteHomeResolutionKey = nil
|
||||
self.remoteHomeResolutionTask = nil
|
||||
self.setRootPath("")
|
||||
self.setRootStatusMessage(
|
||||
String(
|
||||
localized: "fileExplorer.status.sshHomeFailed",
|
||||
defaultValue: "Unable to resolve SSH home: \(error.localizedDescription)"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelRemoteHomeResolution() {
|
||||
remoteHomeResolutionTask?.cancel()
|
||||
remoteHomeResolutionTask = nil
|
||||
remoteHomeResolutionKey = nil
|
||||
}
|
||||
|
||||
private func setRootStatusMessage(_ message: String?) {
|
||||
guard rootStatusMessage != message else { return }
|
||||
rootStatusMessage = message
|
||||
}
|
||||
|
||||
private static func path(_ candidate: String, isContainedIn root: String) -> Bool {
|
||||
guard !root.isEmpty else { return false }
|
||||
if root == "/" {
|
||||
@@ -723,6 +1084,10 @@ final class FileExplorerStore: ObservableObject {
|
||||
}
|
||||
return candidate == root || candidate.hasPrefix(root + "/")
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancelRemoteHomeResolution()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Directory Watcher
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import AppKit
|
||||
|
||||
enum FileExplorerTerminalPathInsertion {
|
||||
static func insertedText(forPaths paths: [String]) -> String {
|
||||
TerminalImageTransferPlanner.insertedText(forPathStrings: paths)
|
||||
}
|
||||
|
||||
static func insertedText(forPaths paths: [String], relativeToRootPath rootPath: String) -> String {
|
||||
insertedText(forPaths: paths.map { relativePath(for: $0, rootPath: rootPath) })
|
||||
}
|
||||
|
||||
static func relativePath(for path: String, rootPath: String) -> String {
|
||||
let normalizedPath = normalizedFileSystemPath(path)
|
||||
guard !rootPath.isEmpty else { return normalizedPath }
|
||||
let normalizedRootPath = normalizedFileSystemPath(rootPath)
|
||||
if normalizedPath == normalizedRootPath { return "." }
|
||||
let normalizedRoot = normalizedRootPath == "/" ? "/" : normalizedRootPath + "/"
|
||||
if normalizedPath.hasPrefix(normalizedRoot) {
|
||||
return String(normalizedPath.dropFirst(normalizedRoot.count))
|
||||
}
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
private static func normalizedFileSystemPath(_ path: String) -> String {
|
||||
let path = pathWithoutTrailingSlashes(path)
|
||||
guard path.hasPrefix("/") else { return path }
|
||||
return macOSDisplayPath(
|
||||
pathWithoutTrailingSlashes(URL(fileURLWithPath: path).standardizedFileURL.path)
|
||||
)
|
||||
}
|
||||
|
||||
private static func macOSDisplayPath(_ path: String) -> String {
|
||||
let rewrites = [
|
||||
(privatePath: "/private/tmp", displayPath: "/tmp"),
|
||||
(privatePath: "/private/var", displayPath: "/var"),
|
||||
(privatePath: "/private/etc", displayPath: "/etc"),
|
||||
]
|
||||
for rewrite in rewrites {
|
||||
if path == rewrite.privatePath {
|
||||
return rewrite.displayPath
|
||||
}
|
||||
if path.hasPrefix(rewrite.privatePath + "/") {
|
||||
return rewrite.displayPath + String(path.dropFirst(rewrite.privatePath.count))
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
private static func pathWithoutTrailingSlashes(_ path: String) -> String {
|
||||
var result = path
|
||||
while result.count > 1 && result.hasSuffix("/") {
|
||||
result.removeLast()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
static func insert(paths: [String], relativeToRootPath rootPath: String? = nil, intoTerminalFor window: NSWindow?) -> Bool {
|
||||
let text: String
|
||||
if let rootPath {
|
||||
text = insertedText(forPaths: paths, relativeToRootPath: rootPath)
|
||||
} else {
|
||||
text = insertedText(forPaths: paths)
|
||||
}
|
||||
guard !text.isEmpty else { return false }
|
||||
|
||||
guard let terminalPanel = targetTerminalPanel(for: window) else { return false }
|
||||
terminalPanel.sendText(text)
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func targetTerminalPanel(for window: NSWindow?) -> TerminalPanel? {
|
||||
guard let appDelegate = AppDelegate.shared else { return nil }
|
||||
if let window,
|
||||
let terminalPanel = appDelegate.contextForMainTerminalWindow(window)?.tabManager.selectedWorkspace?.focusedTerminalPanel {
|
||||
return terminalPanel
|
||||
}
|
||||
if let window,
|
||||
let windowId = appDelegate.mainWindowId(from: window),
|
||||
let terminalPanel = appDelegate.tabManagerFor(windowId: windowId)?.selectedWorkspace?.focusedTerminalPanel {
|
||||
return terminalPanel
|
||||
}
|
||||
return appDelegate.tabManager?.selectedWorkspace?.focusedTerminalPanel
|
||||
}
|
||||
}
|
||||
|
||||
extension NSMenu {
|
||||
func addFileExplorerInsertPathItems(
|
||||
target: AnyObject,
|
||||
representedObject: Any,
|
||||
insertAction: Selector,
|
||||
insertRelativeAction: Selector
|
||||
) {
|
||||
let insertPathItem = NSMenuItem(
|
||||
title: String(localized: "fileExplorer.contextMenu.insertPath", defaultValue: "Insert Path"),
|
||||
action: insertAction,
|
||||
keyEquivalent: ""
|
||||
)
|
||||
insertPathItem.target = target
|
||||
insertPathItem.representedObject = representedObject
|
||||
addItem(insertPathItem)
|
||||
|
||||
let insertRelativePathItem = NSMenuItem(
|
||||
title: String(localized: "fileExplorer.contextMenu.insertRelativePath", defaultValue: "Insert Relative Path"),
|
||||
action: insertRelativeAction,
|
||||
keyEquivalent: ""
|
||||
)
|
||||
insertRelativePathItem.target = target
|
||||
insertRelativePathItem.representedObject = representedObject
|
||||
addItem(insertRelativePathItem)
|
||||
}
|
||||
}
|
||||
|
||||
extension FileExplorerPanelView.Coordinator {
|
||||
@MainActor
|
||||
private func contextMenuNodes(clicked node: FileExplorerNode) -> [FileExplorerNode] {
|
||||
guard let outlineView else { return [node] }
|
||||
let clickedRow = outlineView.clickedRow
|
||||
let selectedRows = outlineView.selectedRowIndexes
|
||||
guard clickedRow >= 0, selectedRows.contains(clickedRow) else {
|
||||
return [node]
|
||||
}
|
||||
let nodes = selectedRows.compactMap { row -> FileExplorerNode? in
|
||||
guard row >= 0, row < outlineView.numberOfRows else { return nil }
|
||||
return outlineView.item(atRow: row) as? FileExplorerNode
|
||||
}
|
||||
return nodes.isEmpty ? [node] : nodes
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc func contextMenuInsertPath(_ sender: NSMenuItem) {
|
||||
guard let node = sender.representedObject as? FileExplorerNode else { return }
|
||||
FileExplorerTerminalPathInsertion.insert(
|
||||
paths: contextMenuNodes(clicked: node).map(\.path),
|
||||
intoTerminalFor: outlineView?.window ?? containerView?.window
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc func contextMenuInsertRelativePath(_ sender: NSMenuItem) {
|
||||
guard let node = sender.representedObject as? FileExplorerNode else { return }
|
||||
FileExplorerTerminalPathInsertion.insert(
|
||||
paths: contextMenuNodes(clicked: node).map(\.path),
|
||||
relativeToRootPath: store.rootPath,
|
||||
intoTerminalFor: outlineView?.window ?? containerView?.window
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension FileExplorerContainerView {
|
||||
@MainActor
|
||||
private func searchResultsForContextMenu(row: Int) -> [FileSearchResult] {
|
||||
guard row >= 0, row < searchSnapshot.results.count else { return [] }
|
||||
let selectedRows = searchResultsView.selectedRowIndexes
|
||||
guard selectedRows.contains(row) else {
|
||||
return [searchSnapshot.results[row]]
|
||||
}
|
||||
let results = selectedRows.compactMap { selectedRow -> FileSearchResult? in
|
||||
guard selectedRow >= 0, selectedRow < searchSnapshot.results.count else { return nil }
|
||||
return searchSnapshot.results[selectedRow]
|
||||
}
|
||||
return results.isEmpty ? [searchSnapshot.results[row]] : results
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc func contextMenuInsertSearchResultPath(_ sender: NSMenuItem) {
|
||||
guard let row = (sender.representedObject as? NSNumber)?.intValue else { return }
|
||||
FileExplorerTerminalPathInsertion.insert(
|
||||
paths: searchResultsForContextMenu(row: row).map(\.path),
|
||||
intoTerminalFor: window
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc func contextMenuInsertSearchResultRelativePath(_ sender: NSMenuItem) {
|
||||
guard let row = (sender.representedObject as? NSNumber)?.intValue else { return }
|
||||
FileExplorerTerminalPathInsertion.insert(
|
||||
paths: searchResultsForContextMenu(row: row).map(\.relativePath),
|
||||
intoTerminalFor: window
|
||||
)
|
||||
}
|
||||
}
|
||||
+348
-54
@@ -100,15 +100,22 @@ struct FileExplorerPanelView: NSViewRepresentable {
|
||||
observationCancellable = store.objectWillChange
|
||||
.debounce(for: .milliseconds(50), scheduler: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.reloadIfNeeded()
|
||||
Task { @MainActor [weak self] in
|
||||
self?.reloadIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func reloadIfNeeded() {
|
||||
guard let outlineView else { return }
|
||||
|
||||
// Update empty state vs tree visibility
|
||||
containerView?.updateVisibility(hasContent: !store.rootPath.isEmpty, isLoading: store.isRootLoading)
|
||||
containerView?.updateVisibility(
|
||||
hasContent: !store.rootPath.isEmpty,
|
||||
isLoading: store.isRootLoading,
|
||||
statusMessage: store.rootStatusMessage
|
||||
)
|
||||
|
||||
let newCount = store.rootNodes.count
|
||||
withProgrammaticOutlineUpdate {
|
||||
@@ -225,15 +232,11 @@ struct FileExplorerPanelView: NSViewRepresentable {
|
||||
let outlineView = notification.object as? NSOutlineView else {
|
||||
return
|
||||
}
|
||||
guard outlineView.selectedRow >= 0,
|
||||
outlineView.selectedRow < outlineView.numberOfRows,
|
||||
let node = outlineView.item(atRow: outlineView.selectedRow) as? FileExplorerNode else {
|
||||
store.select(node: nil)
|
||||
return
|
||||
}
|
||||
store.select(node: node)
|
||||
let nodes = outlineView.selectedRowIndexes.compactMap { outlineView.item(atRow: $0) as? FileExplorerNode }
|
||||
guard !nodes.isEmpty else { store.select(node: nil); return }
|
||||
let anchor = outlineView.selectedRow >= 0 ? outlineView.item(atRow: outlineView.selectedRow) as? FileExplorerNode : nil
|
||||
store.select(nodes: nodes, anchor: anchor ?? nodes.first)
|
||||
}
|
||||
|
||||
func outlineViewItemDidExpand(_ notification: Notification) {
|
||||
guard let node = notification.userInfo?["NSObject"] as? FileExplorerNode else { return }
|
||||
if !store.isExpanded(node) {
|
||||
@@ -370,6 +373,12 @@ struct FileExplorerPanelView: NSViewRepresentable {
|
||||
fallbackToFirstVisible: Bool,
|
||||
scroll: Bool
|
||||
) {
|
||||
let exactRows = store.selectedPaths.reduce(into: IndexSet()) { if let resolution = selectionResolution(for: $1, in: outlineView), resolution.isExact { $0.insert(resolution.row) } }
|
||||
if !exactRows.isEmpty {
|
||||
withProgrammaticOutlineUpdate { outlineView.selectRowIndexes(exactRows, byExtendingSelection: false) }
|
||||
let anchorRow = store.selectedPath.flatMap { selectionResolution(for: $0, in: outlineView)?.row }
|
||||
if scroll, let row = FileExplorerSelectionRestoration.scrollRow(anchorRow: anchorRow, exactRows: exactRows) { outlineView.scrollRowToVisible(row) }; return
|
||||
}
|
||||
if let selectedPath = store.selectedPath,
|
||||
let resolution = selectionResolution(for: selectedPath, in: outlineView) {
|
||||
selectRow(
|
||||
@@ -402,7 +411,6 @@ struct FileExplorerPanelView: NSViewRepresentable {
|
||||
let row: Int
|
||||
let isExact: Bool
|
||||
}
|
||||
|
||||
private func selectionResolution(for path: String, in outlineView: NSOutlineView) -> SelectionResolution? {
|
||||
var bestAncestor: (row: Int, pathLength: Int)?
|
||||
for row in 0..<outlineView.numberOfRows {
|
||||
@@ -525,6 +533,8 @@ struct FileExplorerPanelView: NSViewRepresentable {
|
||||
menu.addItem(.separator())
|
||||
}
|
||||
|
||||
menu.addFileExplorerInsertPathItems(target: self, representedObject: node, insertAction: #selector(contextMenuInsertPath(_:)), insertRelativeAction: #selector(contextMenuInsertRelativePath(_:)))
|
||||
|
||||
let copyPathItem = NSMenuItem(
|
||||
title: String(localized: "fileExplorer.contextMenu.copyPath", defaultValue: "Copy Path"),
|
||||
action: #selector(contextMenuCopyPath(_:)),
|
||||
@@ -562,14 +572,7 @@ struct FileExplorerPanelView: NSViewRepresentable {
|
||||
|
||||
@objc private func contextMenuCopyRelativePath(_ sender: NSMenuItem) {
|
||||
guard let node = sender.representedObject as? FileExplorerNode else { return }
|
||||
let rootPath = store.rootPath
|
||||
var relativePath = node.path
|
||||
if relativePath.hasPrefix(rootPath) {
|
||||
relativePath = String(relativePath.dropFirst(rootPath.count))
|
||||
if relativePath.hasPrefix("/") {
|
||||
relativePath = String(relativePath.dropFirst())
|
||||
}
|
||||
}
|
||||
let relativePath = FileExplorerTerminalPathInsertion.relativePath(for: node.path, rootPath: store.rootPath)
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(relativePath, forType: .string)
|
||||
}
|
||||
@@ -579,6 +582,7 @@ struct FileExplorerPanelView: NSViewRepresentable {
|
||||
// MARK: - Container View (all-AppKit)
|
||||
|
||||
/// Pure AppKit container holding the header bar and outline view.
|
||||
@MainActor
|
||||
final class FileExplorerContainerView: NSView {
|
||||
private let headerView: FileExplorerHeaderView
|
||||
private let searchBarView: NSView
|
||||
@@ -587,20 +591,45 @@ final class FileExplorerContainerView: NSView {
|
||||
private let scrollView: NSScrollView
|
||||
private let outlineView: FileExplorerNSOutlineView
|
||||
private let searchScrollView: NSScrollView
|
||||
private let searchResultsView: FileExplorerSearchResultsTableView
|
||||
let searchResultsView: FileExplorerSearchResultsTableView
|
||||
private let emptyLabel: NSTextField
|
||||
private let loadingIndicator: NSProgressIndicator
|
||||
private let searchController: FileSearchController
|
||||
private let searchController: any FileSearchControlling
|
||||
private var searchBarHeightConstraint: NSLayoutConstraint!
|
||||
private var searchSnapshot = FileSearchSnapshot.empty
|
||||
private(set) var searchSnapshot = FileSearchSnapshot.empty
|
||||
private var currentRootPath = ""
|
||||
private var currentProviderIsLocal = false
|
||||
private var currentContentRevision = 0
|
||||
private var isSearchVisible = false
|
||||
private let searchDebounceSubject = PassthroughSubject<Int, Never>()
|
||||
private var searchDebounceCancellable: AnyCancellable?
|
||||
private var searchDebounceGeneration = 0
|
||||
private var pendingSearchRefreshAfterSettled = false
|
||||
private var isSearchVisible = false {
|
||||
didSet {
|
||||
if !isSearchVisible {
|
||||
cancelPendingSearchRefresh()
|
||||
pendingSearchRefreshAfterSettled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
private var presentation: FileExplorerPanelPresentation
|
||||
private let coordinator: FileExplorerPanelView.Coordinator
|
||||
private let searchDebounceDelayMilliseconds = 200
|
||||
private let searchBarVisibleHeight: CGFloat = 48
|
||||
|
||||
init(coordinator: FileExplorerPanelView.Coordinator, presentation: FileExplorerPanelPresentation) {
|
||||
#if DEBUG
|
||||
private var debugLastSearchTextChangeUptime: TimeInterval = 0
|
||||
private var debugLastSearchLayoutFieldWidth: CGFloat = -1
|
||||
private var debugLastSearchLayoutStatusWidth: CGFloat = -1
|
||||
private var debugLastLoggedSearchResultCount = -1
|
||||
private var debugLastLoggedSearchStatus = ""
|
||||
#endif
|
||||
|
||||
init(
|
||||
coordinator: FileExplorerPanelView.Coordinator,
|
||||
presentation: FileExplorerPanelPresentation,
|
||||
searchController: (any FileSearchControlling)? = nil
|
||||
) {
|
||||
headerView = FileExplorerHeaderView()
|
||||
searchBarView = NSView()
|
||||
searchField = FileExplorerSearchField()
|
||||
@@ -611,11 +640,12 @@ final class FileExplorerContainerView: NSView {
|
||||
searchResultsView = FileExplorerSearchResultsTableView()
|
||||
emptyLabel = NSTextField(labelWithString: String(localized: "fileExplorer.empty", defaultValue: "No folder open"))
|
||||
loadingIndicator = NSProgressIndicator()
|
||||
searchController = FileSearchController()
|
||||
self.searchController = searchController ?? FileSearchController()
|
||||
self.presentation = presentation
|
||||
self.coordinator = coordinator
|
||||
|
||||
super.init(frame: .zero)
|
||||
configureSearchDebounce()
|
||||
|
||||
// Header
|
||||
headerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -631,6 +661,11 @@ final class FileExplorerContainerView: NSView {
|
||||
searchField.placeholderString = String(localized: "fileExplorer.search.placeholder", defaultValue: "Search files")
|
||||
searchField.font = .systemFont(ofSize: 12, weight: .regular)
|
||||
searchField.focusRingType = .none
|
||||
searchField.cell?.usesSingleLineMode = true
|
||||
searchField.cell?.isScrollable = true
|
||||
searchField.cell?.lineBreakMode = .byClipping
|
||||
searchField.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
searchField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
searchField.delegate = self
|
||||
searchField.onCancel = { [weak self] in
|
||||
self?.closeSearchAndFocusOutline()
|
||||
@@ -656,6 +691,8 @@ final class FileExplorerContainerView: NSView {
|
||||
searchStatusLabel.textColor = .secondaryLabelColor
|
||||
searchStatusLabel.lineBreakMode = .byTruncatingTail
|
||||
searchStatusLabel.maximumNumberOfLines = 1
|
||||
searchStatusLabel.alignment = .left
|
||||
searchStatusLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
searchStatusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
searchBarView.addSubview(searchStatusLabel)
|
||||
|
||||
@@ -681,6 +718,7 @@ final class FileExplorerContainerView: NSView {
|
||||
outlineView.selectionHighlightStyle = .regular
|
||||
outlineView.rowSizeStyle = .default
|
||||
outlineView.indentationPerLevel = FileExplorerStyle.current.indentation
|
||||
outlineView.allowsMultipleSelection = true
|
||||
outlineView.autoresizesOutlineColumn = true
|
||||
outlineView.floatsGroupRows = false
|
||||
outlineView.backgroundColor = .clear
|
||||
@@ -724,6 +762,7 @@ final class FileExplorerContainerView: NSView {
|
||||
searchResultsView.selectionHighlightStyle = .regular
|
||||
searchResultsView.backgroundColor = .clear
|
||||
searchResultsView.rowHeight = 46
|
||||
searchResultsView.allowsMultipleSelection = true
|
||||
searchResultsView.intercellSpacing = NSSize(width: 0, height: 0)
|
||||
searchResultsView.onCancel = { [weak self] in
|
||||
self?.closeSearchAndFocusOutline()
|
||||
@@ -762,7 +801,7 @@ final class FileExplorerContainerView: NSView {
|
||||
searchScrollView.isHidden = true
|
||||
addSubview(searchScrollView)
|
||||
|
||||
searchController.onSnapshotChanged = { [weak self] snapshot in
|
||||
self.searchController.onSnapshotChanged = { [weak self] snapshot in
|
||||
self?.applySearchSnapshot(snapshot)
|
||||
}
|
||||
|
||||
@@ -778,13 +817,14 @@ final class FileExplorerContainerView: NSView {
|
||||
searchBarHeightConstraint,
|
||||
|
||||
searchField.leadingAnchor.constraint(equalTo: searchBarView.leadingAnchor, constant: 8),
|
||||
searchField.centerYAnchor.constraint(equalTo: searchBarView.centerYAnchor),
|
||||
searchField.trailingAnchor.constraint(equalTo: searchBarView.trailingAnchor, constant: -8),
|
||||
searchField.topAnchor.constraint(equalTo: searchBarView.topAnchor, constant: 4),
|
||||
searchField.heightAnchor.constraint(equalToConstant: 24),
|
||||
searchField.widthAnchor.constraint(greaterThanOrEqualToConstant: 120),
|
||||
|
||||
searchStatusLabel.leadingAnchor.constraint(equalTo: searchField.trailingAnchor, constant: 8),
|
||||
searchStatusLabel.trailingAnchor.constraint(equalTo: searchBarView.trailingAnchor, constant: -8),
|
||||
searchStatusLabel.centerYAnchor.constraint(equalTo: searchField.centerYAnchor),
|
||||
searchStatusLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 140),
|
||||
searchStatusLabel.leadingAnchor.constraint(equalTo: searchField.leadingAnchor, constant: 4),
|
||||
searchStatusLabel.trailingAnchor.constraint(equalTo: searchField.trailingAnchor),
|
||||
searchStatusLabel.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 2),
|
||||
|
||||
scrollView.topAnchor.constraint(equalTo: searchBarView.bottomAnchor),
|
||||
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
@@ -810,6 +850,7 @@ final class FileExplorerContainerView: NSView {
|
||||
|
||||
override func viewWillMove(toWindow newWindow: NSWindow?) {
|
||||
if newWindow == nil {
|
||||
cancelPendingSearchRefresh()
|
||||
searchController.cancel(clear: false)
|
||||
}
|
||||
super.viewWillMove(toWindow: newWindow)
|
||||
@@ -834,24 +875,33 @@ final class FileExplorerContainerView: NSView {
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
#if DEBUG
|
||||
let debugLayoutStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
super.layout()
|
||||
registerWithKeyboardFocusCoordinatorIfNeeded()
|
||||
#if DEBUG
|
||||
logSearchLayoutIfNeeded(startedAt: debugLayoutStart, reason: "layout")
|
||||
#endif
|
||||
}
|
||||
|
||||
func updateHeader(store: FileExplorerStore) {
|
||||
let nextRootPath = store.rootPath
|
||||
let nextProviderIsLocal = store.provider is LocalFileExplorerProvider
|
||||
let nextContentRevision = store.contentRevision
|
||||
let shouldRefreshSearch = nextRootPath != currentRootPath ||
|
||||
nextProviderIsLocal != currentProviderIsLocal ||
|
||||
nextContentRevision != currentContentRevision
|
||||
let searchScopeChanged = nextRootPath != currentRootPath ||
|
||||
nextProviderIsLocal != currentProviderIsLocal
|
||||
let contentRevisionChanged = nextContentRevision != currentContentRevision
|
||||
|
||||
currentRootPath = nextRootPath
|
||||
currentProviderIsLocal = nextProviderIsLocal
|
||||
currentContentRevision = nextContentRevision
|
||||
headerView.update(displayPath: store.displayRootPath)
|
||||
if shouldRefreshSearch {
|
||||
if searchScopeChanged {
|
||||
pendingSearchRefreshAfterSettled = false
|
||||
refreshSearchIfNeeded()
|
||||
} else if contentRevisionChanged {
|
||||
refreshSearchAfterContentRevisionIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,7 +914,6 @@ final class FileExplorerContainerView: NSView {
|
||||
if presentation == .find {
|
||||
isSearchVisible = true
|
||||
updateSearchLayout()
|
||||
refreshSearchIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -882,11 +931,17 @@ final class FileExplorerContainerView: NSView {
|
||||
registerWithKeyboardFocusCoordinatorIfNeeded()
|
||||
}
|
||||
|
||||
func updateVisibility(hasContent: Bool, isLoading: Bool) {
|
||||
headerView.isHidden = !hasContent
|
||||
updateSearchLayout(hasContent: hasContent, isLoading: isLoading)
|
||||
let searchCanShow = isSearchVisible && hasContent && !isLoading
|
||||
emptyLabel.isHidden = hasContent || searchCanShow
|
||||
func updateVisibility(hasContent: Bool, isLoading: Bool, statusMessage: String?) {
|
||||
let normalizedStatus = statusMessage?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hasStatus = normalizedStatus?.isEmpty == false
|
||||
let canShowTree = hasContent && !hasStatus
|
||||
headerView.isHidden = !hasContent && !hasStatus
|
||||
updateSearchLayout(hasContent: canShowTree, isLoading: isLoading)
|
||||
let searchCanShow = isSearchVisible && canShowTree && !isLoading
|
||||
emptyLabel.stringValue = hasStatus
|
||||
? normalizedStatus!
|
||||
: String(localized: "fileExplorer.empty", defaultValue: "No folder open")
|
||||
emptyLabel.isHidden = canShowTree || searchCanShow || isLoading
|
||||
loadingIndicator.isHidden = !isLoading
|
||||
if isLoading {
|
||||
loadingIndicator.startAnimation(nil)
|
||||
@@ -981,6 +1036,15 @@ final class FileExplorerContainerView: NSView {
|
||||
|
||||
private func refreshSearchIfNeeded() {
|
||||
guard isSearchVisible else { return }
|
||||
cancelPendingSearchRefresh()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"file.search.request queryLen=\(searchField.stringValue.count) " +
|
||||
"rootReady=\(currentRootPath.isEmpty ? 0 : 1) local=\(currentProviderIsLocal ? 1 : 0) " +
|
||||
"revision=\(currentContentRevision) results=\(searchSnapshot.results.count) " +
|
||||
"fieldW=\(debugSearchNumber(searchField.frame.width)) statusW=\(debugSearchNumber(searchStatusLabel.frame.width))"
|
||||
)
|
||||
#endif
|
||||
searchController.search(
|
||||
query: searchField.stringValue,
|
||||
rootPath: currentRootPath,
|
||||
@@ -989,34 +1053,139 @@ final class FileExplorerContainerView: NSView {
|
||||
)
|
||||
}
|
||||
|
||||
private func refreshSearchAfterContentRevisionIfNeeded() {
|
||||
guard isSearchVisible else {
|
||||
pendingSearchRefreshAfterSettled = false
|
||||
return
|
||||
}
|
||||
guard searchSnapshot.isSearching else {
|
||||
pendingSearchRefreshAfterSettled = false
|
||||
refreshSearchIfNeeded()
|
||||
return
|
||||
}
|
||||
pendingSearchRefreshAfterSettled = true
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"file.search.contentRevision.defer queryLen=\(searchField.stringValue.count) " +
|
||||
"revision=\(currentContentRevision) results=\(searchSnapshot.results.count)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func configureSearchDebounce() {
|
||||
searchDebounceCancellable = searchDebounceSubject
|
||||
.debounce(for: .milliseconds(searchDebounceDelayMilliseconds), scheduler: RunLoop.main)
|
||||
.sink { [weak self] debounceGeneration in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self,
|
||||
self.isSearchVisible,
|
||||
self.searchDebounceGeneration == debounceGeneration else { return }
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"file.search.debounce.fire queryLen=\(self.searchField.stringValue.count) " +
|
||||
"delayMs=\(self.searchDebounceDelayMilliseconds)"
|
||||
)
|
||||
#endif
|
||||
self.refreshSearchIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleSearchRefresh() {
|
||||
guard isSearchVisible else { return }
|
||||
pendingSearchRefreshAfterSettled = false
|
||||
searchDebounceGeneration += 1
|
||||
let debounceGeneration = searchDebounceGeneration
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"file.search.debounce.schedule queryLen=\(searchField.stringValue.count) " +
|
||||
"delayMs=\(searchDebounceDelayMilliseconds)"
|
||||
)
|
||||
#endif
|
||||
searchDebounceSubject.send(debounceGeneration)
|
||||
}
|
||||
|
||||
private func cancelPendingSearchRefresh() {
|
||||
searchDebounceGeneration += 1
|
||||
}
|
||||
|
||||
private func updateSearchLayout(hasContent: Bool? = nil, isLoading: Bool? = nil) {
|
||||
let effectiveHasContent = hasContent ?? !currentRootPath.isEmpty
|
||||
let effectiveIsLoading = isLoading ?? false
|
||||
let showSearch = isSearchVisible && effectiveHasContent && !effectiveIsLoading
|
||||
searchBarView.isHidden = !showSearch
|
||||
searchBarHeightConstraint.constant = showSearch ? RightSidebarChromeMetrics.secondaryBarHeight : 0
|
||||
searchBarHeightConstraint.constant = showSearch ? searchBarVisibleHeight : 0
|
||||
searchScrollView.isHidden = !showSearch
|
||||
scrollView.isHidden = showSearch || !effectiveHasContent || effectiveIsLoading
|
||||
needsLayout = true
|
||||
}
|
||||
|
||||
private func applySearchSnapshot(_ snapshot: FileSearchSnapshot) {
|
||||
#if DEBUG
|
||||
let debugApplyStart = ProcessInfo.processInfo.systemUptime
|
||||
let previousStatusName = debugSearchStatusName(searchSnapshot.status)
|
||||
let previousStatusTextLength = searchStatusLabel.stringValue.count
|
||||
#endif
|
||||
let previousSelectedRow = searchResultsView.selectedRow
|
||||
let previousResults = searchSnapshot.results
|
||||
searchSnapshot = snapshot
|
||||
searchStatusLabel.stringValue = statusText(for: snapshot)
|
||||
searchResultsView.reloadData()
|
||||
applySearchResultsUpdate(previousResults: previousResults, nextResults: snapshot.results)
|
||||
#if DEBUG
|
||||
logSearchSnapshot(
|
||||
snapshot,
|
||||
startedAt: debugApplyStart,
|
||||
previousStatusName: previousStatusName,
|
||||
previousStatusTextLength: previousStatusTextLength
|
||||
)
|
||||
#endif
|
||||
|
||||
guard !snapshot.results.isEmpty else { return }
|
||||
let selectedRow = previousSelectedRow >= 0
|
||||
? min(previousSelectedRow, snapshot.results.count - 1)
|
||||
: 0
|
||||
searchResultsView.selectRowIndexes(IndexSet(integer: selectedRow), byExtendingSelection: false)
|
||||
let shouldRunDeferredContentRefresh = !snapshot.isSearching && pendingSearchRefreshAfterSettled
|
||||
if shouldRunDeferredContentRefresh {
|
||||
pendingSearchRefreshAfterSettled = false
|
||||
}
|
||||
|
||||
if !snapshot.results.isEmpty {
|
||||
let selectedRow = previousSelectedRow >= 0
|
||||
? min(previousSelectedRow, snapshot.results.count - 1)
|
||||
: 0
|
||||
searchResultsView.selectRowIndexes(IndexSet(integer: selectedRow), byExtendingSelection: false)
|
||||
}
|
||||
|
||||
if shouldRunDeferredContentRefresh {
|
||||
refreshSearchIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func applySearchResultsUpdate(previousResults: [FileSearchResult], nextResults: [FileSearchResult]) {
|
||||
if previousResults == nextResults {
|
||||
return
|
||||
}
|
||||
|
||||
if nextResults.count > previousResults.count &&
|
||||
nextResults.starts(with: previousResults) {
|
||||
let insertedRange = previousResults.count..<nextResults.count
|
||||
searchResultsView.insertRows(at: IndexSet(integersIn: insertedRange), withAnimation: [])
|
||||
return
|
||||
}
|
||||
|
||||
if nextResults.count == previousResults.count {
|
||||
let changedRows = IndexSet(
|
||||
nextResults.indices.filter { nextResults[$0] != previousResults[$0] }
|
||||
)
|
||||
if !changedRows.isEmpty {
|
||||
searchResultsView.reloadData(forRowIndexes: changedRows, columnIndexes: IndexSet(integer: 0))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
searchResultsView.reloadData()
|
||||
}
|
||||
|
||||
private func statusText(for snapshot: FileSearchSnapshot) -> String {
|
||||
switch snapshot.status {
|
||||
case .idle:
|
||||
return String(localized: "fileExplorer.search.empty", defaultValue: "Type to search")
|
||||
return ""
|
||||
case .unsupported:
|
||||
return String(localized: "fileExplorer.search.unsupported", defaultValue: "Local folders only")
|
||||
case .searching:
|
||||
@@ -1044,9 +1213,103 @@ final class FileExplorerContainerView: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func debugSearchNumber(_ value: CGFloat) -> String {
|
||||
String(format: "%.1f", Double(value))
|
||||
}
|
||||
|
||||
private func debugSearchNumber(_ value: Double) -> String {
|
||||
String(format: "%.1f", value)
|
||||
}
|
||||
|
||||
private func debugSearchStatusName(_ status: FileSearchSnapshot.Status) -> String {
|
||||
switch status {
|
||||
case .idle:
|
||||
return "idle"
|
||||
case .unsupported:
|
||||
return "unsupported"
|
||||
case .searching:
|
||||
return "searching"
|
||||
case .noMatches:
|
||||
return "noMatches"
|
||||
case .matches:
|
||||
return "matches"
|
||||
case .limited(let limit):
|
||||
return "limited(\(limit))"
|
||||
case .failed:
|
||||
return "failed"
|
||||
}
|
||||
}
|
||||
|
||||
private func logSearchLayoutIfNeeded(startedAt: TimeInterval, reason: String) {
|
||||
guard isSearchVisible else { return }
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
let fieldWidth = searchField.frame.width
|
||||
let statusWidth = searchStatusLabel.frame.width
|
||||
let firstLayout = debugLastSearchLayoutFieldWidth < 0 || debugLastSearchLayoutStatusWidth < 0
|
||||
let fieldDelta = debugLastSearchLayoutFieldWidth >= 0
|
||||
? abs(fieldWidth - debugLastSearchLayoutFieldWidth)
|
||||
: 0
|
||||
let statusDelta = debugLastSearchLayoutStatusWidth >= 0
|
||||
? abs(statusWidth - debugLastSearchLayoutStatusWidth)
|
||||
: 0
|
||||
let layoutMs = max(0, (now - startedAt) * 1000)
|
||||
let widthChanged = fieldDelta > 0.5 || statusDelta > 0.5
|
||||
let slowLayout = layoutMs >= 8
|
||||
guard firstLayout || widthChanged || slowLayout else { return }
|
||||
|
||||
debugLastSearchLayoutFieldWidth = fieldWidth
|
||||
debugLastSearchLayoutStatusWidth = statusWidth
|
||||
let sinceKeyMs = debugLastSearchTextChangeUptime > 0
|
||||
? debugSearchNumber((now - debugLastSearchTextChangeUptime) * 1000)
|
||||
: "n/a"
|
||||
dlog(
|
||||
"file.search.layout reason=\(reason) fieldW=\(debugSearchNumber(fieldWidth)) " +
|
||||
"statusW=\(debugSearchNumber(statusWidth)) fieldDelta=\(debugSearchNumber(fieldDelta)) " +
|
||||
"statusDelta=\(debugSearchNumber(statusDelta)) layoutMs=\(debugSearchNumber(layoutMs)) " +
|
||||
"sinceKeyMs=\(sinceKeyMs) queryLen=\(searchField.stringValue.count) " +
|
||||
"results=\(searchSnapshot.results.count) status=\(debugSearchStatusName(searchSnapshot.status))"
|
||||
)
|
||||
}
|
||||
|
||||
private func logSearchSnapshot(
|
||||
_ snapshot: FileSearchSnapshot,
|
||||
startedAt: TimeInterval,
|
||||
previousStatusName: String,
|
||||
previousStatusTextLength: Int
|
||||
) {
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
let applyMs = max(0, (now - startedAt) * 1000)
|
||||
let statusName = debugSearchStatusName(snapshot.status)
|
||||
let resultDelta = debugLastLoggedSearchResultCount >= 0
|
||||
? abs(snapshot.results.count - debugLastLoggedSearchResultCount)
|
||||
: snapshot.results.count
|
||||
let shouldLog = statusName != debugLastLoggedSearchStatus ||
|
||||
resultDelta >= 50 ||
|
||||
applyMs >= 4
|
||||
guard shouldLog else { return }
|
||||
|
||||
debugLastLoggedSearchStatus = statusName
|
||||
debugLastLoggedSearchResultCount = snapshot.results.count
|
||||
let sinceKeyMs = debugLastSearchTextChangeUptime > 0
|
||||
? debugSearchNumber((now - debugLastSearchTextChangeUptime) * 1000)
|
||||
: "n/a"
|
||||
dlog(
|
||||
"file.search.snapshot status=\(statusName) previousStatus=\(previousStatusName) " +
|
||||
"results=\(snapshot.results.count) isSearching=\(snapshot.isSearching ? 1 : 0) " +
|
||||
"applyMs=\(debugSearchNumber(applyMs)) sinceKeyMs=\(sinceKeyMs) " +
|
||||
"fieldW=\(debugSearchNumber(searchField.frame.width)) statusW=\(debugSearchNumber(searchStatusLabel.frame.width)) " +
|
||||
"statusIntrinsicW=\(debugSearchNumber(searchStatusLabel.intrinsicContentSize.width)) " +
|
||||
"statusTextLen=\(searchStatusLabel.stringValue.count) previousStatusTextLen=\(previousStatusTextLength)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func closeSearchAndFocusOutline() {
|
||||
if presentation == .find {
|
||||
let hadQuery = !searchField.stringValue.isEmpty
|
||||
cancelPendingSearchRefresh()
|
||||
pendingSearchRefreshAfterSettled = false
|
||||
searchController.cancel(clear: true)
|
||||
searchField.stringValue = ""
|
||||
applySearchSnapshot(.empty)
|
||||
@@ -1065,6 +1328,7 @@ final class FileExplorerContainerView: NSView {
|
||||
isSearchVisible = false
|
||||
searchController.cancel(clear: true)
|
||||
searchField.stringValue = ""
|
||||
pendingSearchRefreshAfterSettled = false
|
||||
searchSnapshot = .empty
|
||||
searchResultsView.reloadData()
|
||||
updateSearchLayout()
|
||||
@@ -1134,7 +1398,33 @@ final class FileExplorerContainerView: NSView {
|
||||
extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
guard notification.object as? NSTextField === searchField else { return }
|
||||
refreshSearchIfNeeded()
|
||||
scrollSearchFieldEditorToInsertionPoint()
|
||||
Task { @MainActor [weak self] in
|
||||
self?.scrollSearchFieldEditorToInsertionPoint()
|
||||
}
|
||||
#if DEBUG
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
let gapMs = debugLastSearchTextChangeUptime > 0
|
||||
? debugSearchNumber((now - debugLastSearchTextChangeUptime) * 1000)
|
||||
: "n/a"
|
||||
debugLastSearchTextChangeUptime = now
|
||||
dlog(
|
||||
"file.search.input.changed queryLen=\(searchField.stringValue.count) gapMs=\(gapMs) " +
|
||||
"fieldW=\(debugSearchNumber(searchField.frame.width)) statusW=\(debugSearchNumber(searchStatusLabel.frame.width)) " +
|
||||
"statusIntrinsicW=\(debugSearchNumber(searchStatusLabel.intrinsicContentSize.width)) " +
|
||||
"results=\(searchSnapshot.results.count) status=\(debugSearchStatusName(searchSnapshot.status)) " +
|
||||
"fr=\(fileExplorerDebugResponder(window?.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
scheduleSearchRefresh()
|
||||
}
|
||||
|
||||
private func scrollSearchFieldEditorToInsertionPoint() {
|
||||
guard let editor = searchField.currentEditor() else { return }
|
||||
let selection = editor.selectedRange
|
||||
let textLength = (editor.string as NSString).length
|
||||
let cursorLocation = min(selection.location + selection.length, textLength)
|
||||
editor.scrollRangeToVisible(NSRange(location: cursorLocation, length: 0))
|
||||
}
|
||||
|
||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
@@ -1207,7 +1497,7 @@ extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSourc
|
||||
let clickedRow = searchResultsView.clickedRow
|
||||
let row = clickedRow >= 0 ? clickedRow : searchResultsView.selectedRow
|
||||
guard row >= 0, row < searchSnapshot.results.count else { return }
|
||||
if clickedRow >= 0 {
|
||||
if clickedRow >= 0 && !searchResultsView.selectedRowIndexes.contains(clickedRow) {
|
||||
searchResultsView.selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false)
|
||||
}
|
||||
|
||||
@@ -1240,6 +1530,8 @@ extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSourc
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
menu.addFileExplorerInsertPathItems(target: self, representedObject: NSNumber(value: row), insertAction: #selector(contextMenuInsertSearchResultPath(_:)), insertRelativeAction: #selector(contextMenuInsertSearchResultRelativePath(_:)))
|
||||
|
||||
let copyPathItem = NSMenuItem(
|
||||
title: String(localized: "fileExplorer.contextMenu.copyPath", defaultValue: "Copy Path"),
|
||||
action: #selector(contextMenuCopySearchResultPath(_:)),
|
||||
@@ -1310,7 +1602,7 @@ private final class FileExplorerSearchField: NSSearchField {
|
||||
}
|
||||
}
|
||||
|
||||
private final class FileExplorerSearchResultsTableView: NSTableView {
|
||||
final class FileExplorerSearchResultsTableView: NSTableView {
|
||||
var onCancel: (() -> Void)?
|
||||
var onMoveSelection: ((Int) -> Void)?
|
||||
var onCommit: (() -> Void)?
|
||||
@@ -1486,15 +1778,18 @@ final class FileExplorerHeaderView: NSView {
|
||||
}
|
||||
|
||||
private func applyHeaderState() {
|
||||
assert(Thread.isMainThread, "AppKit image updates must run on the main thread")
|
||||
let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .regular)
|
||||
if let quickSearchQuery {
|
||||
iconView.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: nil)?
|
||||
.withSymbolConfiguration(config)
|
||||
pathLabel.stringValue = "/" + quickSearchQuery
|
||||
pathLabel.toolTip = pathLabel.stringValue
|
||||
} else {
|
||||
iconView.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil)?
|
||||
.withSymbolConfiguration(config)
|
||||
pathLabel.stringValue = displayPath
|
||||
pathLabel.toolTip = displayPath
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1580,11 +1875,10 @@ final class FileExplorerCellView: NSTableCellView {
|
||||
}
|
||||
|
||||
func configure(with node: FileExplorerNode, gitStatus: GitFileStatus? = nil) {
|
||||
assert(Thread.isMainThread, "AppKit image updates must run on the main thread")
|
||||
let style = FileExplorerStyle.current
|
||||
|
||||
nameLabel.stringValue = node.name
|
||||
nameLabel.font = style.nameFont
|
||||
|
||||
iconWidthConstraint.constant = style.iconSize
|
||||
iconHeightConstraint.constant = style.iconSize
|
||||
iconToTextConstraint.constant = style.iconToTextSpacing
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import AppKit
|
||||
import Carbon.HIToolbox
|
||||
|
||||
extension GhosttyNSView {
|
||||
/// Clamps AppKit's marked-text selection into the active preedit buffer.
|
||||
func normalizedMarkedSelectionRange(_ range: NSRange, markedLength: Int) -> NSRange {
|
||||
guard markedLength > 0 else {
|
||||
return NSRange(location: NSNotFound, length: 0)
|
||||
}
|
||||
guard range.location != NSNotFound else {
|
||||
return NSRange(location: markedLength, length: 0)
|
||||
}
|
||||
|
||||
let clampedLocation = min(max(range.location, 0), markedLength)
|
||||
let clampedLength = min(max(range.length, 0), markedLength - clampedLocation)
|
||||
return NSRange(location: clampedLocation, length: clampedLength)
|
||||
}
|
||||
|
||||
/// Clamps an AppKit substring query so it can be served from marked text.
|
||||
func clampedMarkedTextRange(_ range: NSRange, markedLength: Int) -> NSRange? {
|
||||
guard range.length > 0, range.location != NSNotFound else { return nil }
|
||||
guard markedLength > 0 else { return nil }
|
||||
|
||||
let location = min(max(range.location, 0), markedLength)
|
||||
let maxLength = markedLength - location
|
||||
guard maxLength > 0 else { return nil }
|
||||
|
||||
let length = min(max(range.length, 0), maxLength)
|
||||
guard length > 0 else { return nil }
|
||||
return NSRange(location: location, length: length)
|
||||
}
|
||||
|
||||
/// Returns true when AppKit consumed the key by changing IME composition state.
|
||||
func shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
|
||||
before: (text: String, selection: NSRange),
|
||||
after: (text: String, selection: NSRange),
|
||||
accumulatedText: [String],
|
||||
event: NSEvent? = nil,
|
||||
textInputHandledEvent: Bool = false,
|
||||
inputSourceId: String? = nil
|
||||
) -> Bool {
|
||||
guard accumulatedText.isEmpty else { return false }
|
||||
|
||||
let hadMarkedTextBefore = !before.text.isEmpty
|
||||
let hasMarkedTextAfter = !after.text.isEmpty
|
||||
guard hadMarkedTextBefore || hasMarkedTextAfter else {
|
||||
guard textInputHandledEvent, isBopomofoInputSource(inputSourceId) else { return false }
|
||||
return shouldKeepNoMarkedIMECommandInsideTextInput(event)
|
||||
}
|
||||
|
||||
if before.text != after.text {
|
||||
return true
|
||||
}
|
||||
|
||||
if before.selection != after.selection {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let event, isInputMethodSource(inputSourceId) else {
|
||||
return false
|
||||
}
|
||||
return shouldKeepIMECompositionCommandInsideTextInput(event)
|
||||
}
|
||||
|
||||
func isInputMethodSource(_ sourceId: String?) -> Bool {
|
||||
guard let sourceId else { return false }
|
||||
return sourceId.localizedCaseInsensitiveContains("inputmethod")
|
||||
}
|
||||
|
||||
func isBopomofoInputSource(_ sourceId: String?) -> Bool {
|
||||
guard let sourceId else { return false }
|
||||
return sourceId.localizedCaseInsensitiveContains("Zhuyin")
|
||||
|| sourceId.localizedCaseInsensitiveContains("Bopomofo")
|
||||
}
|
||||
|
||||
func hasOnlyTextInputCommandModifiers(_ event: NSEvent) -> Bool {
|
||||
let flags = event.modifierFlags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function, .capsLock])
|
||||
return flags.isEmpty || flags == [.shift]
|
||||
}
|
||||
|
||||
func shouldKeepNoMarkedIMECommandInsideTextInput(_ event: NSEvent?) -> Bool {
|
||||
guard let event else { return false }
|
||||
guard hasOnlyTextInputCommandModifiers(event) else { return false }
|
||||
|
||||
switch Int(event.keyCode) {
|
||||
case kVK_DownArrow, kVK_PageUp, kVK_PageDown, kVK_Space:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when a window-level key-equivalent probe should re-enter
|
||||
/// the terminal's keyDown path so AppKit's text input context sees the key
|
||||
/// before terminal bindings or cursor escape sequences do.
|
||||
func shouldRouteTextInputKeyEquivalentToKeyDown(_ event: NSEvent) -> Bool {
|
||||
shouldRouteTextInputKeyEquivalentToKeyDown(event, inputSourceId: nil)
|
||||
}
|
||||
|
||||
func shouldRouteTextInputKeyEquivalentToKeyDown(_ event: NSEvent, inputSourceId: String?) -> Bool {
|
||||
guard event.type == .keyDown else { return false }
|
||||
let resolvedInputSourceId = inputSourceId ?? KeyboardLayout.id
|
||||
if hasMarkedText() {
|
||||
return isInputMethodSource(resolvedInputSourceId)
|
||||
&& shouldKeepIMECompositionCommandInsideTextInput(event)
|
||||
}
|
||||
return isBopomofoInputSource(resolvedInputSourceId)
|
||||
&& shouldKeepNoMarkedIMECommandInsideTextInput(event)
|
||||
}
|
||||
|
||||
/// Returns true for active-composition command keys that belong to AppKit's
|
||||
/// text input manager even when marked text itself does not change.
|
||||
func shouldKeepIMECompositionCommandInsideTextInput(_ event: NSEvent) -> Bool {
|
||||
guard hasOnlyTextInputCommandModifiers(event) else { return false }
|
||||
|
||||
switch Int(event.keyCode) {
|
||||
case kVK_LeftArrow, kVK_RightArrow, kVK_UpArrow, kVK_DownArrow,
|
||||
kVK_PageUp, kVK_PageDown, kVK_Home, kVK_End,
|
||||
kVK_Space, kVK_Return, kVK_ANSI_KeypadEnter, kVK_Escape,
|
||||
kVK_Tab, kVK_Delete, kVK_ForwardDelete:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting(
|
||||
markedTextBefore: String,
|
||||
markedSelectionBefore: NSRange,
|
||||
markedTextAfter: String,
|
||||
markedSelectionAfter: NSRange,
|
||||
accumulatedText: [String],
|
||||
event: NSEvent? = nil,
|
||||
textInputHandledEvent: Bool = false,
|
||||
inputSourceId: String? = nil
|
||||
) -> Bool {
|
||||
shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
|
||||
before: (markedTextBefore, markedSelectionBefore),
|
||||
after: (markedTextAfter, markedSelectionAfter),
|
||||
accumulatedText: accumulatedText,
|
||||
event: event,
|
||||
textInputHandledEvent: textInputHandledEvent,
|
||||
inputSourceId: inputSourceId
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
+468
-147
@@ -299,6 +299,12 @@ enum GhosttyPasteboardHelper {
|
||||
case rejectedImagePayload
|
||||
}
|
||||
|
||||
enum ImageFileListMaterializationResult {
|
||||
case saved([URL])
|
||||
case noDecodableImagePayload
|
||||
case rejectedImagePayload
|
||||
}
|
||||
|
||||
private static let selectionPasteboard = NSPasteboard(
|
||||
name: NSPasteboard.Name("com.mitchellh.ghostty.selection")
|
||||
)
|
||||
@@ -351,6 +357,14 @@ enum GhosttyPasteboardHelper {
|
||||
return richText
|
||||
}
|
||||
|
||||
if let plainText,
|
||||
PasteboardTextFidelity.shouldInspectRichTextForPlainTextLoss(plainText),
|
||||
types.contains(where: isRichTextType),
|
||||
let richText = richTextContents(from: pasteboard),
|
||||
PasteboardTextFidelity.shouldPreferRichText(richText, overPlainText: plainText) {
|
||||
return richText
|
||||
}
|
||||
|
||||
// Match upstream Ghostty's fast plain-text path for normal text paste.
|
||||
// Large clipboard payloads often also advertise HTML/RTF variants, and
|
||||
// eagerly rendering those rich-text flavors makes Cmd-V much slower than
|
||||
@@ -473,6 +487,10 @@ enum GhosttyPasteboardHelper {
|
||||
return utType.conforms(to: .plainText)
|
||||
}
|
||||
|
||||
private static func isRichTextType(_ type: NSPasteboard.PasteboardType) -> Bool {
|
||||
type == .html || type == .rtf || type == .rtfd
|
||||
}
|
||||
|
||||
private static func attributedString(
|
||||
from pasteboard: NSPasteboard,
|
||||
type: NSPasteboard.PasteboardType,
|
||||
@@ -493,20 +511,34 @@ enum GhosttyPasteboardHelper {
|
||||
)
|
||||
}
|
||||
|
||||
private static func rtfdAttachmentImageRepresentation(
|
||||
in pasteboard: NSPasteboard
|
||||
) -> (data: Data, fileExtension: String)? {
|
||||
guard let attributed = attributedString(
|
||||
from: pasteboard,
|
||||
type: .rtfd,
|
||||
documentType: .rtfd
|
||||
) else { return nil }
|
||||
private static func attributedString(
|
||||
from item: NSPasteboardItem,
|
||||
type: NSPasteboard.PasteboardType,
|
||||
documentType: NSAttributedString.DocumentType
|
||||
) -> NSAttributedString? {
|
||||
let data =
|
||||
item.data(forType: type)
|
||||
?? item.string(forType: type)?.data(using: .utf8)
|
||||
guard let data else { return nil }
|
||||
|
||||
var result: (data: Data, fileExtension: String)?
|
||||
return try? NSAttributedString(
|
||||
data: data,
|
||||
options: [
|
||||
.documentType: documentType,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
],
|
||||
documentAttributes: nil
|
||||
)
|
||||
}
|
||||
|
||||
private static func rtfdAttachmentImageRepresentations(
|
||||
from attributed: NSAttributedString
|
||||
) -> [(data: Data, fileExtension: String)] {
|
||||
var results: [(data: Data, fileExtension: String)] = []
|
||||
attributed.enumerateAttribute(
|
||||
.attachment,
|
||||
in: NSRange(location: 0, length: attributed.length)
|
||||
) { value, _, stop in
|
||||
) { value, _, _ in
|
||||
guard let attachment = value as? NSTextAttachment else { return }
|
||||
|
||||
if let fileWrapper = attachment.fileWrapper,
|
||||
@@ -515,12 +547,33 @@ enum GhosttyPasteboardHelper {
|
||||
data: data,
|
||||
preferredFilename: fileWrapper.preferredFilename
|
||||
) {
|
||||
result = imageRepresentation
|
||||
stop.pointee = true
|
||||
results.append(imageRepresentation)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return results
|
||||
}
|
||||
|
||||
private static func rtfdAttachmentImageRepresentations(
|
||||
in pasteboard: NSPasteboard
|
||||
) -> [(data: Data, fileExtension: String)] {
|
||||
guard let attributed = attributedString(
|
||||
from: pasteboard,
|
||||
type: .rtfd,
|
||||
documentType: .rtfd
|
||||
) else { return [] }
|
||||
return rtfdAttachmentImageRepresentations(from: attributed)
|
||||
}
|
||||
|
||||
private static func rtfdAttachmentImageRepresentations(
|
||||
in item: NSPasteboardItem
|
||||
) -> [(data: Data, fileExtension: String)] {
|
||||
guard let attributed = attributedString(
|
||||
from: item,
|
||||
type: .rtfd,
|
||||
documentType: .rtfd
|
||||
) else { return [] }
|
||||
return rtfdAttachmentImageRepresentations(from: attributed)
|
||||
}
|
||||
|
||||
private static func imageAttachmentRepresentation(
|
||||
@@ -533,6 +586,9 @@ enum GhosttyPasteboardHelper {
|
||||
if let type = !pathExtension.isEmpty ? UTType(filenameExtension: pathExtension) : nil,
|
||||
type.conforms(to: .image),
|
||||
let fileExtension = type.preferredFilenameExtension ?? nonEmpty(pathExtension) {
|
||||
if isTIFFType(type) {
|
||||
return normalizedPNGRepresentation(from: data)
|
||||
}
|
||||
return (data, fileExtension)
|
||||
}
|
||||
|
||||
@@ -541,9 +597,38 @@ enum GhosttyPasteboardHelper {
|
||||
let type = UTType(typeIdentifier),
|
||||
type.conforms(to: .image),
|
||||
let fileExtension = type.preferredFilenameExtension else { return nil }
|
||||
if isTIFFType(type) {
|
||||
return normalizedPNGRepresentation(from: data)
|
||||
}
|
||||
return (data, fileExtension)
|
||||
}
|
||||
|
||||
private static func imageDataRepresentation(
|
||||
data: Data,
|
||||
type: NSPasteboard.PasteboardType
|
||||
) -> (data: Data, fileExtension: String)? {
|
||||
guard let utType = UTType(type.rawValue),
|
||||
utType.conforms(to: .image),
|
||||
let fileExtension = utType.preferredFilenameExtension,
|
||||
!fileExtension.isEmpty else { return nil }
|
||||
if isTIFFType(utType) {
|
||||
return normalizedPNGRepresentation(from: data)
|
||||
}
|
||||
return (data, fileExtension)
|
||||
}
|
||||
|
||||
private static func isTIFFType(_ type: UTType) -> Bool {
|
||||
type == .tiff || type.conforms(to: .tiff)
|
||||
}
|
||||
|
||||
private static func normalizedPNGRepresentation(from data: Data) -> (data: Data, fileExtension: String)? {
|
||||
guard let image = NSImage(data: data),
|
||||
let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
|
||||
return (pngData, "png")
|
||||
}
|
||||
|
||||
private static func nonEmpty(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
@@ -570,70 +655,218 @@ enum GhosttyPasteboardHelper {
|
||||
|
||||
for type in pasteboard.types ?? [] {
|
||||
guard type != .png,
|
||||
type != .tiff,
|
||||
let utType = UTType(type.rawValue),
|
||||
utType.conforms(to: .image),
|
||||
let imageData = pasteboard.data(forType: type),
|
||||
let fileExtension = utType.preferredFilenameExtension,
|
||||
!fileExtension.isEmpty else { continue }
|
||||
return (imageData, fileExtension)
|
||||
let representation = imageDataRepresentation(data: imageData, type: type) else { continue }
|
||||
return representation
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func directImageRepresentation(
|
||||
in item: NSPasteboardItem
|
||||
) -> (data: Data, fileExtension: String)? {
|
||||
if let pngData = item.data(forType: .png) {
|
||||
return (pngData, "png")
|
||||
}
|
||||
|
||||
for type in item.types {
|
||||
guard type != .png,
|
||||
let imageData = item.data(forType: type),
|
||||
let representation = imageDataRepresentation(data: imageData, type: type) else { continue }
|
||||
return representation
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func fallbackImageRepresentation(
|
||||
in item: NSPasteboardItem
|
||||
) -> (data: Data, fileExtension: String)? {
|
||||
for type in item.types {
|
||||
guard let utType = UTType(type.rawValue),
|
||||
utType.conforms(to: .image),
|
||||
let data = item.data(forType: type),
|
||||
let normalized = normalizedPNGRepresentation(from: data) else { continue }
|
||||
return normalized
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func fallbackImageRepresentation(
|
||||
in pasteboard: NSPasteboard
|
||||
) -> (data: Data, fileExtension: String)? {
|
||||
guard hasImageData(in: pasteboard),
|
||||
let tiffData = NSImage(pasteboard: pasteboard)?.tiffRepresentation else { return nil }
|
||||
return normalizedPNGRepresentation(from: tiffData)
|
||||
}
|
||||
|
||||
private static func imageRepresentations(
|
||||
in item: NSPasteboardItem
|
||||
) -> [(data: Data, fileExtension: String)] {
|
||||
if let directImage = directImageRepresentation(in: item) {
|
||||
return [directImage]
|
||||
}
|
||||
let rtfdAttachments = rtfdAttachmentImageRepresentations(in: item)
|
||||
if !rtfdAttachments.isEmpty {
|
||||
return rtfdAttachments
|
||||
}
|
||||
if let fallbackImage = fallbackImageRepresentation(in: item) {
|
||||
return [fallbackImage]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func pasteboardFallbackImageRepresentations(
|
||||
for item: NSPasteboardItem
|
||||
) -> [(data: Data, fileExtension: String)] {
|
||||
guard let copiedItem = copiedPasteboardItem(from: item) else { return [] }
|
||||
|
||||
let pasteboard = NSPasteboard(name: .init("cmux-single-image-item-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
defer {
|
||||
pasteboard.clearContents()
|
||||
pasteboard.releaseGlobally()
|
||||
}
|
||||
guard pasteboard.writeObjects([copiedItem]) else { return [] }
|
||||
|
||||
if let directImage = directImageRepresentation(in: pasteboard) {
|
||||
return [directImage]
|
||||
}
|
||||
let rtfdAttachments = rtfdAttachmentImageRepresentations(in: pasteboard)
|
||||
if !rtfdAttachments.isEmpty {
|
||||
return rtfdAttachments
|
||||
}
|
||||
if let fallbackImage = fallbackImageRepresentation(in: pasteboard) {
|
||||
return [fallbackImage]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func copiedPasteboardItem(from item: NSPasteboardItem) -> NSPasteboardItem? {
|
||||
let copiedItem = NSPasteboardItem()
|
||||
var copiedAnyType = false
|
||||
|
||||
for type in item.types {
|
||||
if let data = item.data(forType: type) {
|
||||
copiedAnyType = copiedItem.setData(data, forType: type) || copiedAnyType
|
||||
continue
|
||||
}
|
||||
|
||||
if let string = item.string(forType: type) {
|
||||
copiedAnyType = copiedItem.setString(string, forType: type) || copiedAnyType
|
||||
}
|
||||
}
|
||||
|
||||
return copiedAnyType ? copiedItem : nil
|
||||
}
|
||||
|
||||
private static func imageRepresentations(
|
||||
in pasteboard: NSPasteboard
|
||||
) -> [(data: Data, fileExtension: String)] {
|
||||
let itemRepresentations = (pasteboard.pasteboardItems ?? [])
|
||||
.flatMap { item in
|
||||
let representations = imageRepresentations(in: item)
|
||||
if !representations.isEmpty {
|
||||
return representations
|
||||
}
|
||||
return pasteboardFallbackImageRepresentations(for: item)
|
||||
}
|
||||
if !itemRepresentations.isEmpty {
|
||||
return itemRepresentations
|
||||
}
|
||||
if let directImage = directImageRepresentation(in: pasteboard) {
|
||||
return [directImage]
|
||||
}
|
||||
let rtfdAttachments = rtfdAttachmentImageRepresentations(in: pasteboard)
|
||||
if !rtfdAttachments.isEmpty {
|
||||
return rtfdAttachments
|
||||
}
|
||||
if let fallbackImage = fallbackImageRepresentation(in: pasteboard) {
|
||||
return [fallbackImage]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func materializeImageFileURLs(
|
||||
from representations: [(data: Data, fileExtension: String)]
|
||||
) -> ImageFileListMaterializationResult {
|
||||
guard !representations.isEmpty else { return .noDecodableImagePayload }
|
||||
|
||||
let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
|
||||
var fileURLs: [URL] = []
|
||||
for representation in representations {
|
||||
guard representation.data.count <= maxClipboardImageSize else {
|
||||
#if DEBUG
|
||||
cmuxDebugLog("terminal.paste.image.rejected reason=tooLarge bytes=\(representation.data.count)")
|
||||
#endif
|
||||
cleanupTransferredTemporaryImageFiles(fileURLs)
|
||||
return .rejectedImagePayload
|
||||
}
|
||||
|
||||
let fileURL = temporaryImageFileURL(fileExtension: representation.fileExtension)
|
||||
|
||||
do {
|
||||
try representation.data.write(to: fileURL)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
cmuxDebugLog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
|
||||
#endif
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
cleanupTransferredTemporaryImageFiles(fileURLs)
|
||||
return .rejectedImagePayload
|
||||
}
|
||||
|
||||
registerOwnedTemporaryImageFile(fileURL)
|
||||
fileURLs.append(fileURL)
|
||||
}
|
||||
|
||||
return .saved(fileURLs)
|
||||
}
|
||||
|
||||
private static func temporaryImageFileURL(fileExtension: String) -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
let timestamp = formatter.string(from: Date())
|
||||
let filename = "\(temporaryImageFilenamePrefix)\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
|
||||
return FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
/// Attempts to materialize a decodable pasteboard image into a temporary file.
|
||||
/// `rejectedImagePayload` means a real image was found but could not be used,
|
||||
/// so callers should not fall back to auxiliary plain text or URLs.
|
||||
static func materializeImageFileURLIfNeeded(
|
||||
from pasteboard: NSPasteboard = .general
|
||||
) -> ImageFileMaterializationResult {
|
||||
let imageData: Data
|
||||
let fileExtension: String
|
||||
if let directImage = directImageRepresentation(in: pasteboard) {
|
||||
imageData = directImage.data
|
||||
fileExtension = directImage.fileExtension
|
||||
} else if let rtfdAttachment = rtfdAttachmentImageRepresentation(in: pasteboard) {
|
||||
imageData = rtfdAttachment.data
|
||||
fileExtension = rtfdAttachment.fileExtension
|
||||
} else {
|
||||
guard hasImageData(in: pasteboard),
|
||||
let image = NSImage(pasteboard: pasteboard),
|
||||
let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let pngData = bitmap.representation(using: .png, properties: [:]) else {
|
||||
return .noDecodableImagePayload
|
||||
}
|
||||
imageData = pngData
|
||||
fileExtension = "png"
|
||||
}
|
||||
|
||||
let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
|
||||
guard imageData.count <= maxClipboardImageSize else {
|
||||
#if DEBUG
|
||||
cmuxDebugLog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)")
|
||||
#endif
|
||||
let representations = Array(imageRepresentations(in: pasteboard).prefix(1))
|
||||
switch materializeImageFileURLs(from: representations) {
|
||||
case .saved(let fileURLs):
|
||||
guard let fileURL = fileURLs.first else { return .noDecodableImagePayload }
|
||||
return .saved(fileURL)
|
||||
case .noDecodableImagePayload:
|
||||
return .noDecodableImagePayload
|
||||
case .rejectedImagePayload:
|
||||
return .rejectedImagePayload
|
||||
}
|
||||
}
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
let timestamp = formatter.string(from: Date())
|
||||
let filename = "\(temporaryImageFilenamePrefix)\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
|
||||
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
||||
static func materializeImageFileURLsIfNeeded(
|
||||
from pasteboard: NSPasteboard = .general
|
||||
) -> ImageFileListMaterializationResult {
|
||||
materializeImageFileURLs(from: imageRepresentations(in: pasteboard))
|
||||
}
|
||||
|
||||
do {
|
||||
try imageData.write(to: fileURL)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
cmuxDebugLog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
|
||||
#endif
|
||||
return .rejectedImagePayload
|
||||
static func saveImageFileURLsIfNeeded(
|
||||
from pasteboard: NSPasteboard = .general,
|
||||
assumeNoText: Bool = false
|
||||
) -> [URL] {
|
||||
if !assumeNoText && stringContents(from: pasteboard) != nil { return [] }
|
||||
|
||||
guard case .saved(let fileURLs) = materializeImageFileURLsIfNeeded(from: pasteboard) else {
|
||||
return []
|
||||
}
|
||||
|
||||
registerOwnedTemporaryImageFile(fileURL)
|
||||
return .saved(fileURL)
|
||||
return fileURLs
|
||||
}
|
||||
|
||||
/// When the clipboard contains only image data (or rich text that resolves to
|
||||
@@ -673,6 +906,17 @@ enum GhosttyPasteboardHelper {
|
||||
}
|
||||
}
|
||||
|
||||
static func cleanupAllOwnedTemporaryImageFiles() {
|
||||
temporaryImageOwnershipLock.lock()
|
||||
let paths = ownedTemporaryImagePaths
|
||||
ownedTemporaryImagePaths.removeAll()
|
||||
temporaryImageOwnershipLock.unlock()
|
||||
|
||||
for path in paths {
|
||||
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
|
||||
}
|
||||
}
|
||||
|
||||
private static func registerOwnedTemporaryImageFile(_ fileURL: URL) {
|
||||
let normalizedPath = fileURL.standardizedFileURL.path
|
||||
temporaryImageOwnershipLock.lock()
|
||||
@@ -1866,8 +2110,9 @@ class GhosttyApp {
|
||||
return
|
||||
}
|
||||
|
||||
let fallbackShouldUseHostLayerBackground = usesHostLayerBackground(for: fallbackConfig)
|
||||
loadInlineGhosttyConfig(
|
||||
"macos-background-from-layer = true",
|
||||
"macos-background-from-layer = \(fallbackShouldUseHostLayerBackground)",
|
||||
into: fallbackConfig,
|
||||
prefix: "cmux-renderer-bg",
|
||||
logLabel: "renderer background (fallback)"
|
||||
@@ -1880,7 +2125,7 @@ class GhosttyApp {
|
||||
)
|
||||
loadCmuxOwnedGhosttyKeybindOverrides(fallbackConfig)
|
||||
let fallbackRenderingModeChanged = setUsesHostLayerBackground(
|
||||
true,
|
||||
fallbackShouldUseHostLayerBackground,
|
||||
source: "initialize.fallbackConfig"
|
||||
)
|
||||
ghostty_config_finalize(fallbackConfig)
|
||||
@@ -2016,15 +2261,17 @@ class GhosttyApp {
|
||||
}
|
||||
#endif
|
||||
loadCJKFontFallbackIfNeeded(config)
|
||||
let shouldUseHostLayerBackground = usesHostLayerBackground(for: config)
|
||||
let renderingModeChanged = setUsesHostLayerBackground(
|
||||
true,
|
||||
shouldUseHostLayerBackground,
|
||||
source: "loadDefaultConfigFilesWithLegacyFallback"
|
||||
)
|
||||
// Let cmux own the window-level backdrop once, while Ghostty keeps
|
||||
// rendering text, cell backgrounds, and background images. This avoids
|
||||
// separate translucent fills for terminal and chrome surfaces.
|
||||
// Let Ghostty paint solid opaque terminal backgrounds so default cells
|
||||
// and explicit ANSI background cells share one renderer/compositor path.
|
||||
// Host-layer ownership remains required for translucent and blurred
|
||||
// terminal backgrounds.
|
||||
loadInlineGhosttyConfig(
|
||||
"macos-background-from-layer = true",
|
||||
"macos-background-from-layer = \(shouldUseHostLayerBackground)",
|
||||
into: config,
|
||||
prefix: "cmux-renderer-bg",
|
||||
logLabel: "renderer background"
|
||||
@@ -2055,17 +2302,21 @@ class GhosttyApp {
|
||||
}
|
||||
|
||||
private func loadCmuxOwnedGhosttyKeybindOverrides(_ config: ghostty_config_t) {
|
||||
// cmux owns these split shortcuts through KeyboardShortcutSettings.
|
||||
// cmux owns these split and close shortcuts through KeyboardShortcutSettings.
|
||||
// Remove Ghostty's default fallbacks so remapped or cleared shortcuts
|
||||
// can reach the focused terminal instead of creating a split.
|
||||
// can reach the focused terminal instead of splitting or closing outside
|
||||
// the remappable shortcut layer.
|
||||
loadInlineGhosttyConfig(
|
||||
"""
|
||||
keybind = super+d=unbind
|
||||
keybind = super+shift+d=unbind
|
||||
keybind = super+w=unbind
|
||||
keybind = super+alt+w=unbind
|
||||
keybind = super+shift+w=unbind
|
||||
""",
|
||||
into: config,
|
||||
prefix: "cmux-owned-split-keybind-overrides",
|
||||
logLabel: "cmux-owned split keybind overrides"
|
||||
prefix: "cmux-owned-keybind-overrides",
|
||||
logLabel: "cmux-owned keybind overrides"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2978,10 +3229,7 @@ class GhosttyApp {
|
||||
let resolvedCursorText = ghosttyColorValue(from: config, key: "cursor-text", fallback: baseline.cursorTextColor)
|
||||
let resolvedSelectionBackground = ghosttyColorValue(from: config, key: "selection-background", fallback: baseline.selectionBackground)
|
||||
let resolvedSelectionForeground = ghosttyColorValue(from: config, key: "selection-foreground", fallback: baseline.selectionForeground)
|
||||
var opacity = baseline.backgroundOpacity
|
||||
let opacityKey = "background-opacity"
|
||||
_ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8)))
|
||||
opacity = min(1.0, max(0.0, opacity))
|
||||
let opacity = defaultBackgroundOpacityValue(from: config)
|
||||
let backgroundBlur = defaultBackgroundBlurValue(from: config)
|
||||
applyDefaultBackground(
|
||||
color: resolvedColor,
|
||||
@@ -2998,6 +3246,20 @@ class GhosttyApp {
|
||||
)
|
||||
}
|
||||
|
||||
private func defaultBackgroundOpacityValue(from config: ghostty_config_t) -> Double {
|
||||
var opacity = Self.fallbackAppearanceConfig.backgroundOpacity
|
||||
let key = "background-opacity"
|
||||
_ = ghostty_config_get(config, &opacity, key, UInt(key.lengthOfBytes(using: .utf8)))
|
||||
return Double(WindowAppearanceSnapshot.clampedOpacity(opacity))
|
||||
}
|
||||
|
||||
private func usesHostLayerBackground(for config: ghostty_config_t) -> Bool {
|
||||
WindowAppearanceSnapshot.usesHostLayerBackground(
|
||||
backgroundOpacity: defaultBackgroundOpacityValue(from: config),
|
||||
backgroundBlur: defaultBackgroundBlurValue(from: config)
|
||||
)
|
||||
}
|
||||
|
||||
private func defaultBackgroundBlurValue(from config: ghostty_config_t) -> GhosttyBackgroundBlur {
|
||||
var value: Int16 = 0
|
||||
let key = "background-blur"
|
||||
@@ -4180,19 +4442,19 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
||||
var portOrdinal: Int = 0
|
||||
/// Snapshotted once per app session so all workspaces use consistent values
|
||||
private static let sessionPortBase: Int = {
|
||||
let val = UserDefaults.standard.integer(forKey: "cmuxPortBase")
|
||||
return val > 0 ? val : 9100
|
||||
let val = UserDefaults.standard.integer(forKey: AutomationSettings.portBaseKey)
|
||||
return val > 0 ? val : AutomationSettings.defaultPortBase
|
||||
}()
|
||||
private static let sessionPortRangeSize: Int = {
|
||||
let val = UserDefaults.standard.integer(forKey: "cmuxPortRange")
|
||||
return val > 0 ? val : 10
|
||||
let val = UserDefaults.standard.integer(forKey: AutomationSettings.portRangeKey)
|
||||
return val > 0 ? val : AutomationSettings.defaultPortRange
|
||||
}()
|
||||
private let surfaceContext: ghostty_surface_context_e
|
||||
private let configTemplate: CmuxSurfaceConfigTemplate?
|
||||
private let workingDirectory: String?
|
||||
private let initialCommand: String?
|
||||
private let tmuxStartCommand: String?
|
||||
private let initialInput: String?
|
||||
let initialCommand: String?
|
||||
let tmuxStartCommand: String?
|
||||
let initialInput: String?
|
||||
private let initialEnvironmentOverrides: [String: String]
|
||||
var requestedWorkingDirectory: String? { workingDirectory }
|
||||
let focusPlacement: TerminalSurfaceFocusPlacement
|
||||
@@ -4295,6 +4557,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
||||
additionalEnvironment: [String: String] = [:],
|
||||
focusPlacement: TerminalSurfaceFocusPlacement = .workspace
|
||||
) {
|
||||
#if DEBUG
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
#endif
|
||||
|
||||
self.id = UUID()
|
||||
self.tabId = tabId
|
||||
self.surfaceContext = context
|
||||
@@ -4383,14 +4649,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
||||
cmuxSurfaceContextName(surfaceContext)
|
||||
}
|
||||
|
||||
func debugInitialCommand() -> String? {
|
||||
initialCommand
|
||||
}
|
||||
|
||||
func debugTmuxStartCommand() -> String? {
|
||||
tmuxStartCommand
|
||||
}
|
||||
|
||||
func debugPortalHostLease() -> (hostId: String?, paneId: UUID?, inWindow: Bool?, area: CGFloat?) {
|
||||
guard let activePortalHostLease else {
|
||||
return (nil, nil, nil, nil)
|
||||
@@ -5938,6 +6196,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")
|
||||
}()
|
||||
static var debugGhosttySurfaceKeyEventObserver: ((ghostty_input_key_s) -> Void)?
|
||||
@MainActor static var debugTextInputEventHandler: ((GhosttyNSView, NSEvent) -> Bool)?
|
||||
#endif
|
||||
private var eventMonitor: Any?
|
||||
private var trackingArea: NSTrackingArea?
|
||||
@@ -6942,6 +7201,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
let result = super.becomeFirstResponder()
|
||||
var shouldApplySurfaceFocus = false
|
||||
if result {
|
||||
imeSuppressedKeyUpKeyCodes.removeAll()
|
||||
if let terminalSurface,
|
||||
AppDelegate.shared?.allowsTerminalKeyboardFocus(
|
||||
workspaceId: terminalSurface.tabId,
|
||||
@@ -7021,6 +7281,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
if let displayID = window?.screen?.displayID, displayID != 0 {
|
||||
ghostty_surface_set_display_id(surface, displayID)
|
||||
}
|
||||
terminalSurface?.forceRefresh(reason: "focus.firstResponder")
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -7030,6 +7291,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
if result {
|
||||
desiredFocus = false
|
||||
terminalSurface?.recordExternalFocusState(false)
|
||||
imeSuppressedKeyUpKeyCodes.removeAll()
|
||||
}
|
||||
if result, let surface = surface {
|
||||
let now = CACurrentMediaTime()
|
||||
@@ -7042,7 +7304,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
|
||||
// For NSTextInputClient - accumulates text during key events
|
||||
private(set) var keyTextAccumulator: [String]? = nil
|
||||
private var imeSuppressedKeyUpKeyCodes: Set<UInt16> = []
|
||||
private var markedText = NSMutableAttributedString()
|
||||
private var markedSelectedRange = NSRange(location: NSNotFound, length: 0)
|
||||
private var lastPerformKeyEvent: TimeInterval?
|
||||
private(set) var externalCommittedTextDepth = 0
|
||||
var numpadIMECommitDeduplicator = NumpadIMECommitDeduplicator()
|
||||
@@ -7060,20 +7324,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
var keyTextAccumulatorForTesting: [String]? {
|
||||
keyTextAccumulator
|
||||
}
|
||||
func setIMETransientStateForTesting(suppressedKeyUpKeyCodes: Set<UInt16>) {
|
||||
imeSuppressedKeyUpKeyCodes = suppressedKeyUpKeyCodes
|
||||
}
|
||||
var imeSuppressedKeyUpKeyCodesForTesting: Set<UInt16> {
|
||||
imeSuppressedKeyUpKeyCodes
|
||||
}
|
||||
func shouldSuppressShiftSpaceFallbackTextForTesting(event: NSEvent, markedTextBefore: Bool) -> Bool {
|
||||
shouldSuppressShiftSpaceFallbackText(event: event, markedTextBefore: markedTextBefore)
|
||||
}
|
||||
|
||||
// Test-only IME point override so firstRect behavior can be regression tested.
|
||||
private var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)?
|
||||
|
||||
func setIMEPointForTesting(x: Double, y: Double, width: Double, height: Double) {
|
||||
imePointOverrideForTesting = (x, y, width, height)
|
||||
}
|
||||
|
||||
func clearIMEPointForTesting() {
|
||||
imePointOverrideForTesting = nil
|
||||
}
|
||||
func setIMEPointForTesting(x: Double, y: Double, width: Double, height: Double) { imePointOverrideForTesting = (x, y, width, height) }
|
||||
func clearIMEPointForTesting() { imePointOverrideForTesting = nil }
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
@@ -7121,10 +7384,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
fr === self || fr.isDescendant(of: self) else { return false }
|
||||
guard let surface = ensureSurfaceReadyForInput() else { return false }
|
||||
|
||||
// If the IME is composing (marked text present) and the key has no Cmd
|
||||
// modifier, don't intercept — let it flow through to keyDown so the input
|
||||
// method can process it normally. Cmd-based shortcuts should still work
|
||||
// during composition since Cmd is never part of IME input sequences.
|
||||
// Let non-Cmd keys flow to keyDown while IME is composing; Cmd shortcuts still work.
|
||||
if hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) {
|
||||
return false
|
||||
}
|
||||
@@ -7467,25 +7727,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
keyTextAccumulator = []
|
||||
defer { keyTextAccumulator = nil }
|
||||
|
||||
// Track whether we had marked text (IME preedit) before this event,
|
||||
// so we can detect when composition ends.
|
||||
let markedTextBefore = markedText.length > 0
|
||||
let markedStateBefore = (markedText.string, markedSelectedRange)
|
||||
|
||||
// Capture the keyboard layout ID before interpretation so we can
|
||||
// detect if an IME changed it (e.g. toggling input methods).
|
||||
// We only check when not already in a preedit state.
|
||||
let keyboardIdBefore: String? = if (!markedTextBefore) {
|
||||
KeyboardLayout.id
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
// Capture the keyboard layout ID before interpretation so the IME
|
||||
// forwarding decision uses the source that saw this key.
|
||||
let keyboardIdBefore = KeyboardLayout.id
|
||||
|
||||
// Let the input system handle the event (for IME, dead keys, etc.)
|
||||
#if DEBUG
|
||||
let interpretTimingStart = CmuxTypingTiming.start()
|
||||
let interpretPhaseStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
interpretKeyEvents([translationEvent])
|
||||
let textInputHandledEvent = handleTextInputKeyEvent(translationEvent)
|
||||
#if DEBUG
|
||||
interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0
|
||||
CmuxTypingTiming.logDuration(
|
||||
@@ -7497,7 +7751,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
|
||||
// If the keyboard layout changed, an input method grabbed the event.
|
||||
// Sync preedit and return without sending the key to Ghostty.
|
||||
if !markedTextBefore, let kbBefore = keyboardIdBefore, kbBefore != KeyboardLayout.id {
|
||||
if !markedTextBefore, keyboardIdBefore != KeyboardLayout.id {
|
||||
imeSuppressedKeyUpKeyCodes.insert(event.keyCode)
|
||||
#if DEBUG
|
||||
let syncPreeditStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
@@ -7508,8 +7763,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
return
|
||||
}
|
||||
|
||||
// Sync the preedit state with Ghostty so it can render the IME
|
||||
// composition overlay (e.g. for Korean, Japanese, Chinese input).
|
||||
// Sync preedit so Ghostty can render the IME composition overlay.
|
||||
#if DEBUG
|
||||
let syncPreeditStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
@@ -7518,6 +7772,23 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0
|
||||
#endif
|
||||
|
||||
let accumulatedText = keyTextAccumulator ?? []
|
||||
if shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
|
||||
before: markedStateBefore,
|
||||
after: (markedText.string, markedSelectedRange),
|
||||
accumulatedText: accumulatedText,
|
||||
event: translationEvent,
|
||||
textInputHandledEvent: textInputHandledEvent,
|
||||
inputSourceId: keyboardIdBefore
|
||||
) {
|
||||
imeSuppressedKeyUpKeyCodes.insert(event.keyCode)
|
||||
return
|
||||
}
|
||||
|
||||
// A forwarded keyDown owns its keyUp. Clear any stale IME suppression
|
||||
// entry left by an earlier suppressed repeat for the same physical key.
|
||||
imeSuppressedKeyUpKeyCodes.remove(event.keyCode)
|
||||
|
||||
// Build the key event
|
||||
var keyEvent = ghostty_input_key_s()
|
||||
keyEvent.action = action
|
||||
@@ -7527,16 +7798,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
keyEvent.consumed_mods = consumedModsFromFlags(translationMods)
|
||||
keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event)
|
||||
|
||||
// We're composing if we have preedit (the obvious case). But we're also
|
||||
// composing if we don't have preedit and we had marked text before,
|
||||
// because this input probably just reset the preedit state. It shouldn't
|
||||
// be encoded. Example: Japanese begin composing, then press backspace.
|
||||
// This should only cancel the composing state but not actually delete
|
||||
// the prior input characters (prior to the composing).
|
||||
// Treat cleared preedit as composing too, so a composing Backspace cancels
|
||||
// composition without deleting the preceding terminal input.
|
||||
keyEvent.composing = markedText.length > 0 || markedTextBefore
|
||||
|
||||
// Use accumulated text from insertText (for IME), or compute text for key
|
||||
let accumulatedText = keyTextAccumulator ?? []
|
||||
var shouldRefreshAfterTextInput = false
|
||||
if !accumulatedText.isEmpty {
|
||||
// Accumulated text comes from insertText (IME composition result).
|
||||
@@ -7703,6 +7969,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
// Rendering is driven by Ghostty's wakeups/renderer.
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func handleTextInputKeyEvent(_ event: NSEvent) -> Bool {
|
||||
#if DEBUG
|
||||
if let debugTextInputEventHandler = Self.debugTextInputEventHandler {
|
||||
return debugTextInputEventHandler(self, event)
|
||||
}
|
||||
#endif
|
||||
guard let inputContext else {
|
||||
interpretKeyEvents([event])
|
||||
return false
|
||||
}
|
||||
return inputContext.handleEvent(event)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func sendGhosttyKey(_ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s) -> Bool {
|
||||
#if DEBUG
|
||||
@@ -7735,6 +8015,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
#endif
|
||||
|
||||
override func keyUp(with event: NSEvent) {
|
||||
if imeSuppressedKeyUpKeyCodes.remove(event.keyCode) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
guard let surface = ensureSurfaceReadyForInput() else {
|
||||
super.keyUp(with: event)
|
||||
return
|
||||
@@ -9090,6 +9374,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
) {
|
||||
case .insertText(let text):
|
||||
return .insertText(text)
|
||||
case .insertTextSegments(let segments, _):
|
||||
return .insertText(segments.joined())
|
||||
case .uploadFiles(let fileURLs, _):
|
||||
return .uploadFiles(fileURLs)
|
||||
case .reject:
|
||||
@@ -9240,7 +9526,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func handleDroppedFileURLs(_ urls: [URL]) -> Bool {
|
||||
func handleDroppedFileURLs(_ urls: [URL]) -> Bool {
|
||||
executePreparedImageTransfer(
|
||||
.fileURLs(urls),
|
||||
onCancel: {}
|
||||
@@ -9272,24 +9558,54 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
case .fileURLs(let fileURLs):
|
||||
let plan = TerminalImageTransferPlanner.plan(
|
||||
fileURLs: fileURLs,
|
||||
target: resolvedImageTransferTarget()
|
||||
target: resolvedImageTransferTarget(),
|
||||
mode: .drop
|
||||
)
|
||||
return executeImageTransferPlan(plan, onCancel: onCancel)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
fileprivate enum DebugDropPayloadKind {
|
||||
case fileURLs
|
||||
case imageData
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
fileprivate func debugSimulateFileDrop(paths: [String]) -> Bool {
|
||||
fileprivate func debugSimulateFileDrop(
|
||||
paths: [String],
|
||||
asImageData: Bool = false
|
||||
) -> Bool {
|
||||
guard !paths.isEmpty else { return false }
|
||||
let urls = paths.map { URL(fileURLWithPath: $0) as NSURL }
|
||||
let pbName = NSPasteboard.Name("cmux.debug.drop.\(UUID().uuidString)")
|
||||
let pasteboard = NSPasteboard(name: pbName)
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects(urls)
|
||||
switch asImageData ? DebugDropPayloadKind.imageData : .fileURLs {
|
||||
case .fileURLs:
|
||||
let urls = paths.map { URL(fileURLWithPath: $0) as NSURL }
|
||||
pasteboard.writeObjects(urls)
|
||||
case .imageData:
|
||||
let items = paths.compactMap { path -> NSPasteboardItem? in
|
||||
let url = URL(fileURLWithPath: path)
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let type = debugImagePasteboardType(for: url) else { return nil }
|
||||
let item = NSPasteboardItem()
|
||||
item.setData(data, forType: type)
|
||||
return item
|
||||
}
|
||||
guard items.count == paths.count else { return false }
|
||||
pasteboard.writeObjects(items)
|
||||
}
|
||||
return insertDroppedPasteboard(pasteboard)
|
||||
}
|
||||
|
||||
private func debugImagePasteboardType(for url: URL) -> NSPasteboard.PasteboardType? {
|
||||
let pathExtension = url.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let utType = UTType(filenameExtension: pathExtension),
|
||||
utType.conforms(to: .image) else { return nil }
|
||||
return NSPasteboard.PasteboardType(utType.identifier)
|
||||
}
|
||||
|
||||
fileprivate func debugRegisteredDropTypes() -> [String] {
|
||||
(registeredDraggedTypes ?? []).map(\.rawValue)
|
||||
}
|
||||
@@ -9678,6 +9994,10 @@ final class GhosttySurfaceScrollView: NSView {
|
||||
}
|
||||
|
||||
init(surfaceView: GhosttyNSView) {
|
||||
#if DEBUG
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
#endif
|
||||
|
||||
self.surfaceView = surfaceView
|
||||
backgroundView = NSView(frame: .zero)
|
||||
scrollView = GhosttyScrollView()
|
||||
@@ -10786,20 +11106,7 @@ final class GhosttySurfaceScrollView: NSView {
|
||||
}
|
||||
|
||||
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
|
||||
let padding: CGFloat = 4
|
||||
let localFrame: CGRect
|
||||
switch zone {
|
||||
case .center:
|
||||
localFrame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
|
||||
case .left:
|
||||
localFrame = CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
|
||||
case .right:
|
||||
localFrame = CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
|
||||
case .top:
|
||||
localFrame = CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding)
|
||||
case .bottom:
|
||||
localFrame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
|
||||
}
|
||||
let localFrame = PaneDropRouting.compactOverlayFrame(for: zone, in: size)
|
||||
|
||||
let container = dropZoneOverlayView.superview ?? superview
|
||||
guard let container, container !== self else { return localFrame }
|
||||
@@ -11163,8 +11470,8 @@ final class GhosttySurfaceScrollView: NSView {
|
||||
|
||||
#if DEBUG
|
||||
@discardableResult
|
||||
func debugSimulateFileDrop(paths: [String]) -> Bool {
|
||||
surfaceView.debugSimulateFileDrop(paths: paths)
|
||||
func debugSimulateFileDrop(paths: [String], asImageData: Bool = false) -> Bool {
|
||||
surfaceView.debugSimulateFileDrop(paths: paths, asImageData: asImageData)
|
||||
}
|
||||
|
||||
func debugPendingSurfaceSize() -> CGSize? {
|
||||
@@ -12724,7 +13031,13 @@ extension GhosttyNSView: NSTextInputClient {
|
||||
}
|
||||
|
||||
func selectedRange() -> NSRange {
|
||||
readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0)
|
||||
if markedText.length > 0 {
|
||||
#if DEBUG
|
||||
assert(markedSelectedRange.location != NSNotFound, "markedSelectedRange must be valid")
|
||||
#endif
|
||||
return markedSelectedRange
|
||||
}
|
||||
return readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0)
|
||||
}
|
||||
|
||||
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
|
||||
@@ -12744,8 +13057,9 @@ extension GhosttyNSView: NSTextInputClient {
|
||||
case let v as String:
|
||||
markedText = NSMutableAttributedString(string: v)
|
||||
default:
|
||||
break
|
||||
return
|
||||
}
|
||||
markedSelectedRange = normalizedMarkedSelectionRange(selectedRange, markedLength: markedText.length)
|
||||
|
||||
// If we're not in a keyDown event, sync preedit immediately.
|
||||
// This can happen due to external events like changing keyboard layouts
|
||||
@@ -12770,6 +13084,7 @@ extension GhosttyNSView: NSTextInputClient {
|
||||
#endif
|
||||
if markedText.length > 0 {
|
||||
markedText.mutableString.setString("")
|
||||
markedSelectedRange = NSRange(location: NSNotFound, length: 0)
|
||||
syncPreedit()
|
||||
invalidateTextInputCoordinates(selectionChanged: true)
|
||||
}
|
||||
@@ -12812,6 +13127,12 @@ extension GhosttyNSView: NSTextInputClient {
|
||||
}
|
||||
|
||||
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
|
||||
if markedText.length > 0 {
|
||||
guard let substringRange = clampedMarkedTextRange(range, markedLength: markedText.length) else { return nil }
|
||||
actualRange?.pointee = substringRange
|
||||
return markedText.attributedSubstring(from: substringRange)
|
||||
}
|
||||
|
||||
guard range.length > 0,
|
||||
let snapshot = readSelectionSnapshot() else { return nil }
|
||||
actualRange?.pointee = snapshot.range
|
||||
|
||||
@@ -29,3 +29,18 @@ func shouldAllowEnsureFocusWindowActivation(
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
extension TerminalSurface {
|
||||
func debugInitialCommand() -> String? {
|
||||
initialCommand
|
||||
}
|
||||
|
||||
func debugTmuxStartCommand() -> String? {
|
||||
tmuxStartCommand
|
||||
}
|
||||
|
||||
func debugInitialInputMetadata() -> (hasInitialInput: Bool, byteCount: Int) {
|
||||
let byteCount = initialInput?.utf8.count ?? 0
|
||||
return (byteCount > 0, byteCount)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user