diff --git a/DESIGN.md b/DESIGN.md index 82b52130..9839822b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -167,7 +167,7 @@ agent's option picker without summoning it). Holding `CTRL` opens **KEY COMMANDS**, a two-tab TALLY popover on the shortcut panel's grammar: COMMANDS is a hairline grid of keycap chords (⇧↩ · ⌃C ×2 · ⌥⌫, and the user's own, in custom ink), CUSTOM SETUP is the agent editor's numbered list with an -inline composer — bake-off record in `local-plan/key-commands-bakeoff/`. The +inline composer. The speech-bubble key beside `RET` (`RET · talk · keyboard`, latched while open) opens **Talkback**, the chat-style message box: on visionOS a rounded card in its own slab hanging below the console row, on iPad and iPhone the same card @@ -178,7 +178,14 @@ text field, and a filled ↑ that sends the message as one paste — attached files upload on pick and show as 46 pt previews (28 pt thumbs and compact document chips on the phone), their paths typed first. Chat grammar on the chassis: rounded, hairlined, chassis-grounded, so it still belongs to the -window — bake-off record in `local-plan/talkback-bakeoff/` (Candidate B). +window (bake-off Candidate B). +A confirmed path or web link first opens in a **side panel** beside the live +terminal, never resizing it: a shadowed card in front of the pane on iPad, one +slab beside the glass on visionOS (bake-off candidate A, "Sidecar"). Its +single action row — a header on iPad, a +bottom bar on visionOS where the eye works the lower edge — leads with the +panel's own controls, `✕` and `↗ TAB` (moves it into a tab), then the viewer's +identity and its local controls (expanding as the width allows). The iPad toolbar keeps the full chip set: `DECK`, source label, lamp, then diff --git a/Multiplex/Design/Chassis.swift b/Multiplex/Design/Chassis.swift index abb32bf9..49f5f395 100644 --- a/Multiplex/Design/Chassis.swift +++ b/Multiplex/Design/Chassis.swift @@ -890,3 +890,36 @@ extension UIViewController { scrollView.verticalScrollIndicatorInsets.bottom = inset } } + +extension UIKitChassisChip { + /// A chip in a rail: hugs its caption and never compresses, so a row + /// decides what fits by measuring the chips themselves. + static func rail( + _ caption: String, + systemImage: String? = nil, + prominent: Bool = false, + accessibilityLabel: String, + action: @escaping () -> Void + ) -> UIKitChassisChip { + let chip = UIKitChassisChip( + caption, + systemImage: systemImage, + prominent: prominent, + accessibilityLabel: accessibilityLabel, + action: action + ) + chip.setContentHuggingPriority(.required, for: .horizontal) + chip.setContentCompressionResistancePriority(.required, for: .horizontal) + return chip + } + + /// Dimmed and inert — the way a rail disables a chip. + var isEnabled: Bool { + get { isUserInteractionEnabled } + set { + isUserInteractionEnabled = newValue + alpha = newValue ? 1 : 0.45 + accessibilityTraits = newValue ? .button : [.button, .notEnabled] + } + } +} diff --git a/Multiplex/InfoPlist.xcstrings b/Multiplex/InfoPlist.xcstrings index 7ceff10d..d427cf69 100644 --- a/Multiplex/InfoPlist.xcstrings +++ b/Multiplex/InfoPlist.xcstrings @@ -1,6 +1,32 @@ { "sourceLanguage" : "en", "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Multiplex" + } + } + }, + "shouldTranslate" : false + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Multiplex" + } + } + }, + "shouldTranslate" : false + }, "NSCameraUsageDescription" : { "extractionState" : "manual", "localizations" : { diff --git a/Multiplex/Localizable.xcstrings b/Multiplex/Localizable.xcstrings index 124f5ddc..f40d89e4 100644 --- a/Multiplex/Localizable.xcstrings +++ b/Multiplex/Localizable.xcstrings @@ -1074,6 +1074,38 @@ } } }, + "%@ tab, active, with side panel" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@タブ、アクティブ、サイドパネルあり" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 標籤頁,使用中,附有側邊面板" + } + } + } + }, + "%@ tab, with side panel" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@タブ、サイドパネルあり" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 標籤頁,附有側邊面板" + } + } + } + }, "%@ theme" : { "localizations" : { "ja" : { @@ -2882,6 +2914,24 @@ } } }, + "Camera…" : { + "comment" : "Label for a menu item that opens the camera.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カメラ…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "相機…" + } + } + } + }, "CAN'T DIFF %@" : { "localizations" : { "ja" : { @@ -3010,6 +3060,24 @@ } } }, + "Cancel dictation" : { + "comment" : "Text for a button that cancels a dictation.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "音声入力をキャンセル" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消聽寫" + } + } + } + }, "Cancel finding message" : { "localizations" : { "ja" : { @@ -3544,6 +3612,22 @@ } } }, + "Close file viewer panel" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイルビューアパネルを閉じる" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉檔案檢視器面板" + } + } + } + }, "Close message box" : { "localizations" : { "ja" : { @@ -3672,6 +3756,22 @@ } } }, + "Close viewport panel" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ビューポートパネルを閉じる" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉檢視區面板" + } + } + } + }, "Close Window" : { "localizations" : { "ja" : { @@ -5114,6 +5214,22 @@ } } }, + "Dictate" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "音声入力" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "聽寫" + } + } + } + }, "DICTATE" : { "localizations" : { "ja" : { @@ -5130,6 +5246,100 @@ } } }, + "Dictation isn't available for this language" : { + "comment" : "Error message when the user tries to start dictation but the language isn't supported.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この言語では音声入力を利用できません" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此語言無法使用聽寫" + } + } + } + }, + "Dictation language" : { + "comment" : "Accessibility label for the dictation language button.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "音声入力の言語" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "聽寫語言" + } + } + } + }, + "Dictation language: %@ %@. Change" : { + "comment" : "The accessibility label for the language", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dictation language: %1$@ %2$@. Change" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "音声入力の言語:%@ %@。変更" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "聽寫語言:%@ %@。更改" + } + } + } + }, + "Dictation stopped unexpectedly" : { + "comment" : "A message that appears in a dialog box when the user stops dictation, and the microphone stops working.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "音声入力が予期せず停止しました" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "聽寫意外停止" + } + } + } + }, + "Diff" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "差分" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "差異" + } + } + } + }, "Directory" : { "localizations" : { "ja" : { @@ -5258,6 +5468,22 @@ } } }, + "Done Selecting" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "選択を完了" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成選取" + } + } + } + }, "DOUBLE TAP" : { "localizations" : { "ja" : { @@ -6214,6 +6440,24 @@ } } }, + "Heard, not typed yet: %@" : { + "comment" : "A description of the text that appears in the \"Heard, not typed yet\" column of the dictation bar. The argument is the text that was spoken by the user.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認識済み、未入力:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已聽到但尚未輸入:%@" + } + } + } + }, "herdr %@ is older than this app speaks — update herdr on the host" : { "localizations" : { "ja" : { @@ -6294,6 +6538,22 @@ } } }, + "Hide File Tree" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイルツリーを隠す" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "隱藏檔案樹" + } + } + } + }, "Hide Key passphrase" : { "localizations" : { "ja" : { @@ -7336,6 +7596,24 @@ } } }, + "Lock Keyboard Closed" : { + "comment" : "Text for the keyboard lock action when the keyboard is locked.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キーボードを閉じたままロック" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "鎖定鍵盤為收起" + } + } + } + }, "LOCK THE KEYBOARD" : { "localizations" : { "ja" : { @@ -7640,6 +7918,24 @@ } } }, + "Microphone access is off in Settings" : { + "comment" : "Error message when the user has denied microphone access.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「設定」でマイクへのアクセスがオフになっています" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "「設定」中的麥克風取用權限已關閉" + } + } + } + }, "Model" : { "localizations" : { "ja" : { @@ -7736,6 +8032,38 @@ } } }, + "More file viewer actions" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイルビューアのその他の操作" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "更多檔案檢視器操作" + } + } + } + }, + "More viewport actions" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ビューポートのその他の操作" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "更多檢視區操作" + } + } + } + }, "MOSH LINK — SRTT FROM EVERY DATAGRAM · LOSS FROM SEQUENCE GAPS" : { "localizations" : { "ja" : { @@ -7966,6 +8294,22 @@ } } }, + "Move file viewer to a tab" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイルビューアをタブに移動" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將檔案檢視器移至標籤頁" + } + } + } + }, "Move script down" : { "localizations" : { "ja" : { @@ -8014,18 +8358,34 @@ } } }, - "Move Up" : { + "Move Up" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "上に移動" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "上移" + } + } + } + }, + "Move viewport to a tab" : { "localizations" : { "ja" : { "stringUnit" : { "state" : "translated", - "value" : "上に移動" + "value" : "ビューポートをタブに移動" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "上移" + "value" : "將檢視區移至標籤頁" } } } @@ -9540,6 +9900,22 @@ } } }, + "Open in System Browser" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "システムのブラウザで開く" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在系統瀏覽器中開啟" + } + } + } + }, "Open in the system browser" : { "localizations" : { "ja" : { @@ -9978,6 +10354,38 @@ } } }, + "Page down" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ページダウン" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向下翻頁" + } + } + } + }, + "Page up" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ページアップ" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向上翻頁" + } + } + } + }, "PAN TO SCROLL" : { "localizations" : { "ja" : { @@ -10752,6 +11160,22 @@ } } }, + "Refresh" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再読み込み" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新整理" + } + } + } + }, "REFRESH dials again." : { "localizations" : { "ja" : { @@ -11238,6 +11662,22 @@ } } }, + "Resize side panel" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "サイドパネルのサイズを変更" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "調整側邊面板大小" + } + } + } + }, "Resize Up" : { "localizations" : { "ja" : { @@ -11558,6 +11998,42 @@ } } }, + "Scan Bind Code" : { + "comment" : "Title of the Bind scanner view controller.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バインドコードをスキャン" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "掃描綁定碼" + } + } + } + }, + "Scan QR code" : { + "comment" : "Label for a button that scans a QR code.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "QRコードをスキャン" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "掃描 QR Code" + } + } + } + }, "Script" : { "localizations" : { "ja" : { @@ -11670,6 +12146,22 @@ } } }, + "Select Source Text" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ソーステキストを選択" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選取原始碼文字" + } + } + } + }, "Select source text to copy" : { "localizations" : { "ja" : { @@ -12098,6 +12590,22 @@ } } }, + "Show File Tree" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイルツリーを表示" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示檔案樹狀結構" + } + } + } + }, "Show herdr shortcuts" : { "localizations" : { "ja" : { @@ -12178,6 +12686,22 @@ } } }, + "Show or hide keyboard. Hold to lock the keyboard closed" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キーボードを表示/非表示。長押しでキーボードを閉じたままロック" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示或隱藏鍵盤。長按可將鍵盤鎖定為收起" + } + } + } + }, "Show source" : { "localizations" : { "ja" : { @@ -12650,6 +13174,22 @@ } } }, + "Source" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ソース" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "原始碼" + } + } + } + }, "Source or diff" : { "localizations" : { "ja" : { @@ -12666,6 +13206,24 @@ } } }, + "Speech recognition access is off in Settings" : { + "comment" : "Error message when the user has denied access to speech recognition.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「設定」で音声認識へのアクセスがオフになっています" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "「設定」中的語音辨識取用權限已關閉" + } + } + } + }, "Split Left / Right" : { "localizations" : { "ja" : { @@ -12988,6 +13546,22 @@ } } }, + "Stop dictation" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "音声入力を停止" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止聽寫" + } + } + } + }, "Stop loading" : { "localizations" : { "ja" : { @@ -14224,6 +14798,24 @@ } } }, + "The microphone couldn't be opened" : { + "comment" : "A message that the microphone couldn't be opened.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マイクを開けませんでした" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法開啟麥克風" + } + } + } + }, "The name labels this host on the deck. Address, port, and user form the SSH destination." : { "localizations" : { "ja" : { @@ -14608,6 +15200,24 @@ } } }, + "This device can’t scan. Paste the bind code instead." : { + "comment" : "Text displayed when a device can't scan for a bind code.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このデバイスではスキャンできません。代わりにバインドコードをペーストしてください。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此裝置無法掃描。請改為貼上綁定碼。" + } + } + } + }, "This device has no network connection" : { "localizations" : { "ja" : { @@ -14881,6 +15491,24 @@ } } }, + "Types what you say into the session as you speak, never pressing Return" : { + "comment" : "Accessibility hint for the dictation button.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "話した内容をそのままセッションに入力します。Returnは押しません" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "邊說邊把你說的話輸入到工作階段,絕不會按下 Return" + } + } + } + }, "UDP port or range" : { "localizations" : { "ja" : { @@ -14977,6 +15605,40 @@ } } }, + "Unlock keyboard" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キーボードのロックを解除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "解除鎖定鍵盤" + } + } + } + }, + "Unlock Keyboard" : { + "comment" : "Text for unlocking the keyboard.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キーボードのロックを解除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "解除鎖定鍵盤" + } + } + } + }, "Unlock Multiplex" : { "localizations" : { "ja" : { @@ -15537,6 +16199,23 @@ } } }, + "Width %@ points" : { + "extractionState" : "stale", + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "幅%@ポイント" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "寬度 %@ 點" + } + } + } + }, "Will verify against %@" : { "localizations" : { "ja" : { @@ -15800,5 +16479,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/Multiplex/Models/SidePanel.swift b/Multiplex/Models/SidePanel.swift new file mode 100644 index 00000000..a3c1ffc3 --- /dev/null +++ b/Multiplex/Models/SidePanel.swift @@ -0,0 +1,199 @@ +import CoreGraphics + +/// The platforms that can present an auxiliary viewer beside a terminal — +/// each remembers its own panel width. +enum SidePanelPlatform: CaseIterable, Equatable { + case iPad + case visionOS +} + +/// How a window presents the panel: the visionOS classic window hangs it +/// from a trailing ornament; iPad and the visionOS Shell lay a card over the +/// pane inside the window. +enum SidePanelPresentationStyle: Equatable { + case iPadOverlay + case visionOrnament +} + +/// Pure side-panel geometry shared by the in-window iPad overlay and the +/// visionOS ornament. +enum SidePanelWidth { + static let minimumPanelWidth: CGFloat = 320 + static let minimumTerminalWidth: CGFloat = 320 + /// visionOS widths; the live limit is geometric (`clampedVisionGeometry`), + /// this range only sanity-caps stored values. + static let visionWidthRange: ClosedRange = 360...2_400 + /// visionOS: the card's right edge starts this far past the glass's + /// trailing edge — the default card overlaps the glass by 80 pt. + static let defaultVisionOverhang: CGFloat = 440 + /// visionOS: the right handle's outer stop — 360 pt of travel from the + /// default, so both handles move both ways. + static let maxVisionOverhang: CGFloat = 800 + /// Reference widths `debug.sidepanelwidth` cycles through — not a grid. + static let visionWidthCycle: [CGFloat] = [400, 520, 680] + /// One VoiceOver increment / decrement of the handle. + static let accessibilityStep: CGFloat = 40 + + static func defaultWidth(for platform: SidePanelPlatform) -> CGFloat { + switch platform { + case .iPad: + 440 + case .visionOS: + 520 + } + } + + /// Keeps the overlay panel usable while reserving the terminal's minimum + /// width. A pane too small to satisfy both sides falls back to the panel + /// minimum; admission prevents that degenerate geometry from mounting. + static func clamped(_ width: CGFloat, paneWidth: CGFloat) -> CGFloat { + let maximum = max(minimumPanelWidth, paneWidth - minimumTerminalWidth) + guard width.isFinite else { return minimumPanelWidth } + return min(maximum, max(minimumPanelWidth, width)) + } + + /// A width as the store keeps it, pane-free: iPad only needs the floor + /// (the pane clamps at use), visionOS the sanity range. + static func clampedStored(_ width: CGFloat, for platform: SidePanelPlatform) -> CGFloat { + switch platform { + case .iPad: + return width.isFinite ? max(minimumPanelWidth, width) : defaultWidth(for: .iPad) + case .visionOS: + return clampedVision(width) + } + } + + /// The overlay card's container inside the pane — `overlayInset` from + /// the pane's top, bottom and trailing edge; the seam's overhang rides + /// along past the card's leading edge so its outside half is hittable. + static let overlayInset: CGFloat = 8 + + static func overlayContainerFrame( + pane: CGRect, + bottom: CGFloat, + width: CGFloat, + leadingOverhang: CGFloat + ) -> CGRect { + let top = pane.minY + overlayInset + return CGRect( + x: pane.maxX - overlayInset - width - leadingOverhang, + y: top, + width: width + leadingOverhang, + height: max(0, bottom - overlayInset - top) + ) + } + + /// A released visionOS drag lands anywhere inside `visionWidthRange`; a + /// non-finite value falls back to the platform default. + static func clampedVision(_ width: CGFloat) -> CGFloat { + guard width.isFinite else { return defaultWidth(for: .visionOS) } + return min(visionWidthRange.upperBound, max(visionWidthRange.lowerBound, width)) + } + + /// visionOS: the ornament hosts a fixed transparent strip twice the + /// window's width, centred on the glass edge, so the card can reach across + /// the terminal or hang `maxVisionOverhang` outside. A drag moves a frame + /// inside it; only a window resize changes it. + static func visionStripWidth(windowWidth: CGFloat) -> CGFloat { + windowWidth.isFinite ? max(0, windowWidth * 2) : 0 + } + + /// ⚠ Stored GLASS-relative (right edge past the glass), never relative to + /// the strip's end: a strip-relative inset outlived a strip-width change + /// and pinned the card at the far end on device. The inner bound depends + /// on the live window (`clampedVisionRightEdge`). + static func clampedVisionOverhang(_ overhang: CGFloat) -> CGFloat { + guard overhang.isFinite else { return defaultVisionOverhang } + return min(maxVisionOverhang, overhang) + } + + /// The right edge in strip coordinates: at most `maxVisionOverhang` past + /// the glass and inside the strip, at least reserve + minimum width in + /// from the strip's start. + static func clampedVisionRightEdge(_ rightEdge: CGFloat, stripWidth: CGFloat) -> CGFloat { + let strip = stripWidth.isFinite ? max(0, stripWidth) : 0 + let glassEdge = strip / 2 + let outermost = min(strip, glassEdge + maxVisionOverhang) + let innermost = min(outermost, minimumTerminalWidth + visionWidthRange.lowerBound) + let wanted = rightEdge.isFinite ? rightEdge : glassEdge + defaultVisionOverhang + return min(outermost, max(innermost, wanted)) + } + + /// The card inside a strip: right edge as above, then a width inside the + /// range that leaves `minimumTerminalWidth` uncovered (the minimum wins + /// in a window too narrow for both). Stored values stay; a shrunken + /// window clamps live. + static func clampedVisionGeometry( + width: CGFloat, + overhang: CGFloat, + stripWidth: CGFloat + ) -> (width: CGFloat, overhang: CGFloat) { + let strip = stripWidth.isFinite ? max(0, stripWidth) : 0 + let glassEdge = strip / 2 + let rightEdge = clampedVisionRightEdge(glassEdge + overhang, stripWidth: strip) + let room = max(visionWidthRange.lowerBound, rightEdge - minimumTerminalWidth) + let fitted = max(0, min(clampedVision(width), room, rightEdge)) + return (fitted, rightEdge - glassEdge) + } + + /// The left handle: the right edge stays, the left edge follows the + /// finger (`translation` positive = rightward) out to the terminal + /// reserve or in to the minimum width. + static func visionGeometry( + draggingLeadingEdgeBy translation: CGFloat, + from start: (width: CGFloat, overhang: CGFloat), + stripWidth: CGFloat + ) -> (width: CGFloat, overhang: CGFloat) { + clampedVisionGeometry( + width: start.width - translation, + overhang: start.overhang, + stripWidth: stripWidth + ) + } + + /// The right handle: the left edge stays, the right edge follows the + /// finger out to its limit past the glass or in to the minimum width. + static func visionGeometry( + draggingTrailingEdgeBy translation: CGFloat, + from start: (width: CGFloat, overhang: CGFloat), + stripWidth: CGFloat + ) -> (width: CGFloat, overhang: CGFloat) { + let glassEdge = stripWidth / 2 + let leftEdge = glassEdge + start.overhang - start.width + let rightEdge = clampedVisionRightEdge( + max(leftEdge + visionWidthRange.lowerBound, glassEdge + start.overhang + translation), + stripWidth: stripWidth + ) + return clampedVisionGeometry( + width: rightEdge - leftEdge, + overhang: rightEdge - glassEdge, + stripWidth: stripWidth + ) + } +} + +/// Pure admission policy. UIKit supplies the presentation, the active tab +/// kind, pane width, size class, and DEBUG process override. +enum SidePanelPolicy { + static let minimumPaneWidth: CGFloat = 660 + + static func admitsPanel( + style: SidePanelPresentationStyle, + paneWidth: CGFloat, + isCompactWidth: Bool, + anchorIsTerminal: Bool, + environmentOverride: String? + ) -> Bool { + guard environmentOverride != "0", anchorIsTerminal else { return false } + + switch style { + case .iPadOverlay: + // An overlay needs a pane wide enough for card + terminal. + return paneWidth.isFinite + && paneWidth >= minimumPaneWidth + && !isCompactWidth + case .visionOrnament: + return true + } + } +} diff --git a/Multiplex/Services/ExternalActionRouter.swift b/Multiplex/Services/ExternalActionRouter.swift index 17f050a4..05d54d54 100644 --- a/Multiplex/Services/ExternalActionRouter.swift +++ b/Multiplex/Services/ExternalActionRouter.swift @@ -362,6 +362,12 @@ enum ExternalActionPerformer { )) return } + if context.workspace.openFileViewer( + target, + onActiveTerminalForHost: host.id + ) { + return + } let tab = TerminalRoute(hostID: host.id, mode: .fileViewer(path: target.path)) context.workspace.openFileViewer( tab: tab, diff --git a/Multiplex/Services/FileViewerController.swift b/Multiplex/Services/FileViewerController.swift index e233f540..32d5af30 100644 --- a/Multiplex/Services/FileViewerController.swift +++ b/Multiplex/Services/FileViewerController.swift @@ -362,6 +362,13 @@ final class FileViewerController: AuxiliaryPaneController { } } + /// Minted only when ↗ TAB moves this live controller out of a side panel. + /// Prefer what is on screen, then the summons' own path or directory + /// while the first SSH read is still in flight. + var routeMode: TerminalRoute.Mode { + .fileViewer(path: railPath.isEmpty ? target?.path ?? startDirectory ?? "~" : railPath) + } + // MARK: Connection @ObservationIgnored private var connection: SSHConnection? diff --git a/Multiplex/Services/SidePanelWidthStore.swift b/Multiplex/Services/SidePanelWidthStore.swift new file mode 100644 index 00000000..9ff075dc --- /dev/null +++ b/Multiplex/Services/SidePanelWidthStore.swift @@ -0,0 +1,76 @@ +import CoreGraphics +import Foundation +import Observation + +/// Remembered side-panel widths, per platform and device-local. +@MainActor +@Observable +final class SidePanelWidthStore { + static let shared = SidePanelWidthStore() + + private static let iPadKey = "MultiplexSidePanelWidth.iPad" + private static let visionOSKey = "MultiplexSidePanelWidth.visionOS" + private static let visionOverhangKey = "MultiplexSidePanelOverhang.visionOS" + + private let defaults: UserDefaults + private var iPadWidth: CGFloat + private var visionOSWidth: CGFloat + /// visionOS: how far the card's right edge sits past the glass's trailing + /// edge (negative = inside the glass). Moved by the right handle. + private(set) var visionOverhang: CGFloat + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + iPadWidth = Self.storedWidth(for: .iPad, defaults: defaults) + visionOSWidth = Self.storedWidth(for: .visionOS, defaults: defaults) + visionOverhang = (defaults.object(forKey: Self.visionOverhangKey) as? Double) + .map { SidePanelWidth.clampedVisionOverhang(CGFloat($0)) } + ?? SidePanelWidth.defaultVisionOverhang + } + + /// The visionOS card's full geometry in at most two writes. Each value is + /// kept sane on its own; how the pair fits the live window is the strip's + /// business (`SidePanelWidth.clampedVisionGeometry`). + func setVisionGeometry(width: CGFloat, overhang: CGFloat) { + let clampedWidth = SidePanelWidth.clampedVision(width) + if clampedWidth != visionOSWidth { + visionOSWidth = clampedWidth + defaults.set(Double(clampedWidth), forKey: Self.visionOSKey) + } + let clampedOverhang = SidePanelWidth.clampedVisionOverhang(overhang) + if clampedOverhang != visionOverhang { + visionOverhang = clampedOverhang + defaults.set(Double(clampedOverhang), forKey: Self.visionOverhangKey) + } + } + + func width(for platform: SidePanelPlatform) -> CGFloat { + switch platform { + case .iPad: + iPadWidth + case .visionOS: + visionOSWidth + } + } + + func setWidth(_ width: CGFloat, for platform: SidePanelPlatform) { + guard platform == .iPad else { + return setVisionGeometry(width: width, overhang: visionOverhang) + } + let normalized = SidePanelWidth.clampedStored(width, for: .iPad) + guard normalized != iPadWidth else { return } + iPadWidth = normalized + defaults.set(Double(normalized), forKey: Self.iPadKey) + } + + private static func key(for platform: SidePanelPlatform) -> String { + platform == .iPad ? iPadKey : visionOSKey + } + + private static func storedWidth(for platform: SidePanelPlatform, defaults: UserDefaults) -> CGFloat { + guard let stored = defaults.object(forKey: key(for: platform)) as? Double else { + return SidePanelWidth.defaultWidth(for: platform) + } + return SidePanelWidth.clampedStored(CGFloat(stored), for: platform) + } +} diff --git a/Multiplex/Services/TerminalWorkspace.swift b/Multiplex/Services/TerminalWorkspace.swift index 43c9a8a8..747f0fef 100644 --- a/Multiplex/Services/TerminalWorkspace.swift +++ b/Multiplex/Services/TerminalWorkspace.swift @@ -15,6 +15,9 @@ protocol AuxiliaryPaneController: AnyObject { /// The live tab-cell/UMD label (mark + subject) — follows what the pane /// is showing NOW, where the route only knows the summons. var tabLabel: String { get } + /// The route a tab would carry for what the pane shows now — minted when + /// ↗ TAB moves a live side panel into a tab. + var routeMode: TerminalRoute.Mode { get } func shutdown() } @@ -110,11 +113,7 @@ final class TerminalWorkspace { /// without its controller is indistinguishable from a restored corpse. func openViewport(tab: TerminalRoute, offer: ViewportOffer, host: Host) { guard tab.isViewport, auxiliaryControllers[tab.id] == nil else { return } - auxiliaryControllers[tab.id] = ViewportController( - tabID: tab.id, - offer: offer, - host: host - ) + auxiliaryControllers[tab.id] = ViewportController(tabID: tab.id, offer: offer, host: host) } func openFileViewer( @@ -152,12 +151,52 @@ final class TerminalWorkspace { auxiliaryControllers[tabID] as? FileViewerController } + /// Registers a controller already alive under a freshly minted auxiliary + /// tab id. Call before adding that tab to a route, or `syncTabs` will + /// correctly mistake it for a restored corpse. + func adoptAuxiliary(_ controller: any AuxiliaryPaneController, tabID: UUID) { + if let replaced = auxiliaryControllers[tabID], replaced !== controller { + replaced.shutdown() + } + auxiliaryControllers[tabID] = controller + } + + // MARK: Side panels (one per host terminal tab) + + /// Side panels are keyed by the terminal tab that summoned them. That + /// host id survives merge/split, while the controller remains live here. + private var sidePanels: [UUID: any AuxiliaryPaneController] = [:] + + func openSidePanel( + hostTabID: UUID, + controller: any AuxiliaryPaneController + ) { + if let previous = sidePanels[hostTabID], previous !== controller { + previous.shutdown() + } + sidePanels[hostTabID] = controller + } + + func sidePanel(for hostTabID: UUID) -> (any AuxiliaryPaneController)? { + sidePanels[hostTabID] + } + + func closeSidePanel(hostTabID: UUID) { + sidePanels.removeValue(forKey: hostTabID)?.shutdown() + } + + /// Removes a panel without teardown so ↗ TAB can re-parent its live pane. + func detachSidePanel(hostTabID: UUID) -> (any AuxiliaryPaneController)? { + sidePanels.removeValue(forKey: hostTabID) + } + /// Close a tab for real: detach the SSH channel (or shut the auxiliary - /// pane down) and drop the controller. Never called when a tab merely - /// moves. + /// pane down) and drop the controller. A terminal tab owns its side panel, + /// so closing the host releases that viewer too. Never called on a move. func closeTab(_ tabID: UUID) { controllers.removeValue(forKey: tabID)?.detach() auxiliaryControllers.removeValue(forKey: tabID)?.shutdown() + closeSidePanel(hostTabID: tabID) } /// Hosts with at least one live transport right now, whichever window @@ -187,6 +226,10 @@ final class TerminalWorkspace { var surrender: @MainActor () -> [TerminalRoute] /// Appends tabs to the window — the receiving half of a merge. var adopt: @MainActor ([TerminalRoute]) -> Void + /// External file actions may use the active terminal as their anchor + /// when it is on the given host; auxiliary-active windows decline so a + /// document never gets evicted. + var openFileViewer: @MainActor (_ hostID: UUID, TerminalPathTarget) -> Bool = { _, _ in false } } private(set) var windows: [WindowEntry] = [] @@ -208,6 +251,16 @@ final class TerminalWorkspace { windows.filter { $0.id != windowID && !$0.tabs.isEmpty } } + /// Widget/deep-link file summons prefer an already open terminal for the + /// target host. The window runs its ordinary panel admission policy; an + /// auxiliary active tab declines and the caller opens today's fresh tab. + func openFileViewer( + _ target: TerminalPathTarget, + onActiveTerminalForHost hostID: UUID + ) -> Bool { + windows.contains { $0.openFileViewer(hostID, target) } + } + /// True when an open terminal window already has a tab bound to this /// backend's session — pressing that session's deck tile focuses it. func hasTab( diff --git a/Multiplex/Services/ViewportController.swift b/Multiplex/Services/ViewportController.swift index 044ba4e4..a9c31622 100644 --- a/Multiplex/Services/ViewportController.swift +++ b/Multiplex/Services/ViewportController.swift @@ -131,6 +131,8 @@ final class ViewportController: AuxiliaryPaneController { var tabLabel: String { TerminalRoute.viewportLabel(displayURL.absoluteString) } + var routeMode: TerminalRoute.Mode { .viewport(urlString: displayURL.absoluteString) } + func reload() { failure = nil if webView.url == nil { diff --git a/Multiplex/Views/Terminal/FileViewerPane.swift b/Multiplex/Views/Terminal/FileViewerPane.swift index f5075360..abfe0750 100644 --- a/Multiplex/Views/Terminal/FileViewerPane.swift +++ b/Multiplex/Views/Terminal/FileViewerPane.swift @@ -294,8 +294,15 @@ final class FileViewerPaneViewController: UIViewController { self.contentSafeArea = contentSafeArea if isViewLoaded { updateRailInsets() } } - guard self.isActive != isActive else { return } - self.isActive = isActive + setActive(isActive) + } + + /// Side-panel tab switches hide the mount without destroying it. Keep the + /// first load alive, but stop the five-second active-reader watch until + /// its host tab is visible again. + func setActive(_ active: Bool) { + guard isActive != active else { return } + isActive = active if isViewLoaded, startsController { restartWatch() } } diff --git a/Multiplex/Views/Terminal/SidePanelUIKit.swift b/Multiplex/Views/Terminal/SidePanelUIKit.swift new file mode 100644 index 00000000..dc3ad81e --- /dev/null +++ b/Multiplex/Views/Terminal/SidePanelUIKit.swift @@ -0,0 +1,1130 @@ +import CoreImage +import UIKit + +@MainActor +enum SidePanelResizePhase { + case began + case changed + case ended +} + +/// One native mount around an existing auxiliary pane. It owns panel chrome, +/// never the viewer's lifetime: `TerminalWorkspace` remains the only owner +/// that may shut down the SSH connection or web page. +/// +/// Resizing is UIKit-live with the card FROZEN while the finger moves: the +/// first movement renders it into one blurred bitmap, each tick stretches +/// that picture and moves the handles, the release reflows once. Frozen +/// until release on purpose — gaze-and-pinch delivers drag events with gaps, +/// so a mid-drag settle thaw flickered; per-tick reflow and a per-tick +/// system glass were both unusable on device. On visionOS the view is the +/// whole strip the ornament hosts, so a drag never touches the ornament. +@MainActor +final class SidePanelViewController: UIViewController { + static let headerHeight: CGFloat = 30 + /// iPad: the seam's hit strip straddles the card's leading edge — this + /// much outside the card (the window widens the container by it) and + /// `seamInside` over the card. + static let seamOverhang: CGFloat = 12 + static let seamInside: CGFloat = 24 + /// visionOS: each handle reaches this far to both sides of its edge, the + /// card's height above the action row (sharing the row's corner fired ✕). + static let visionHandleReach: CGFloat = 28 + /// visionOS: the header and content sit this far inside the slab, the + /// glass's own silhouette. + static let visionContentInset: CGFloat = 10 + + /// How far the hosting container must extend past the card's leading + /// edge so the seam's outside half is hit-testable (iPad only). + var leadingOverhang: CGFloat { + presentationStyle == .iPadOverlay ? Self.seamOverhang : 0 + } + + let controller: any AuxiliaryPaneController + let paneController: UIViewController + let presentationStyle: SidePanelPresentationStyle + + /// `(width, overhang, phase)`. iPad reports the wanted width (overhang 0) + /// and the window lays the container out; visionOS lays itself out live + /// and reports the geometry to persist on `.ended`. + private let resizeAction: (CGFloat, CGFloat, SidePanelResizePhase) -> Void + private(set) var panelWidth: CGFloat + /// visionOS: the card's right edge past the glass's trailing edge + /// (`SidePanelWidth.clampedVisionGeometry`). + private(set) var overhang: CGFloat + private var dragStart: (width: CGFloat, overhang: CGFloat)? + private var isDragging: Bool { dragStart != nil } + /// The card's picture while the finger moves; nil while the live views + /// are showing (idle, released). + private var dragSnapshot: UIView? + /// Under GLASS the SwiftUI host paints the system glass platter behind + /// the card; it hears the card's frame on the content cadence. + var onCardFrameChange: ((CGRect) -> Void)? + + private let cardView = UIKitTallyBorderedView() + private let headerView: SidePanelHeaderView + private let leadingSeam = SidePanelSeamView() + private let trailingSeam = SidePanelSeamView() + private let widthReadout = ViewportBadgeView("") + private var preparedForRemoval = false + + init( + controller: any AuxiliaryPaneController, + paneController: UIViewController, + presentationStyle: SidePanelPresentationStyle, + width: CGFloat, + overhang: CGFloat = SidePanelWidth.defaultVisionOverhang, + split: @escaping () -> Void, + close: @escaping () -> Void, + resize: @escaping (CGFloat, CGFloat, SidePanelResizePhase) -> Void = { _, _, _ in } + ) { + self.controller = controller + self.paneController = paneController + self.presentationStyle = presentationStyle + headerView = SidePanelHeaderView( + edge: presentationStyle == .visionOrnament ? .bottom : .top, + split: split, + close: close + ) + panelWidth = width + self.overhang = overhang + resizeAction = resize + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("unused") } + + override func loadView() { + let root: UIView = presentationStyle == .visionOrnament + ? SidePanelStripView() + : UIView() + root.backgroundColor = .clear + root.clipsToBounds = false + root.accessibilityIdentifier = "terminal.sidePanel" + view = root + + configureCard() + root.addSubview(cardView) + cardView.addSubview(headerView) + + addChild(paneController) + cardView.addSubview(paneController.view) + paneController.didMove(toParent: self) + + leadingSeam.onResize = { [weak self] translation, phase in + self?.leadingSeamMoved(translation, phase: phase) + } + switch presentationStyle { + case .iPadOverlay: + leadingSeam.pillCenterX = Self.seamOverhang + // On the root, not the card: the strip reaches past the card's edge. + root.addSubview(leadingSeam) + configureWidthReadout() + cardView.addSubview(widthReadout) + case .visionOrnament: + leadingSeam.pillCenterX = Self.visionHandleReach + trailingSeam.pillCenterX = Self.visionHandleReach + trailingSeam.accessibilityIdentifier = "sidePanel.resizeSeam.trailing" + trailingSeam.onResize = { [weak self] translation, phase in + self?.trailingSeamMoved(translation, phase: phase) + } + root.addSubview(leadingSeam) + root.addSubview(trailingSeam) + } + + refreshHeader() + } + + private func configureWidthReadout() { + widthReadout.setText(Self.widthLabel(panelWidth)) + widthReadout.isHidden = true + widthReadout.accessibilityIdentifier = "sidePanel.widthReadout" + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + UIView.performWithoutAnimation { + let bounds = view.bounds + let contentInset: CGFloat + switch presentationStyle { + case .iPadOverlay: + contentInset = 0 + cardView.frame = CGRect( + x: Self.seamOverhang, + y: 0, + width: max(0, bounds.width - Self.seamOverhang), + height: bounds.height + ) + leadingSeam.frame = CGRect( + x: 0, + y: 0, + width: Self.seamOverhang + Self.seamInside, + height: bounds.height + ) + case .visionOrnament: + contentInset = Self.visionContentInset + // The strip starts at the window's leading edge and its + // middle is the glass's trailing edge. A window that shrank + // under the stored geometry clamps here; the values stay. + let geometry = effectiveVisionGeometry + let width = geometry.width + let x = bounds.width / 2 + geometry.overhang - width + cardView.frame = CGRect(x: x, y: 0, width: width, height: bounds.height) + let reach = Self.visionHandleReach + let handleHeight = max( + 0, + bounds.height - Self.headerHeight - Self.visionContentInset + ) + leadingSeam.frame = CGRect(x: x - reach, y: 0, width: reach * 2, height: handleHeight) + trailingSeam.frame = CGRect( + x: x + width - reach, + y: 0, + width: reach * 2, + height: handleHeight + ) + } + if presentationStyle == .iPadOverlay { + widthReadout.frame = CGRect( + origin: CGPoint(x: Self.seamInside + 4, y: Self.headerHeight + 12), + size: widthReadout.intrinsicContentSize + ) + } + // Frozen: the blurred picture stretches with the card, so no + // edge leaves a stale band behind. Nothing below runs per tick. + if let dragSnapshot { + dragSnapshot.frame = cardView.bounds + return + } + let card = cardView.bounds.insetBy(dx: contentInset, dy: contentInset) + let contentHeight = max(0, card.height - Self.headerHeight) + switch headerView.edge { + case .top: + headerView.frame = CGRect( + x: card.minX, y: card.minY, width: card.width, height: Self.headerHeight + ) + paneController.view.frame = CGRect( + x: card.minX, y: card.minY + Self.headerHeight, width: card.width, height: contentHeight + ) + case .bottom: + paneController.view.frame = CGRect( + x: card.minX, y: card.minY, width: card.width, height: contentHeight + ) + headerView.frame = CGRect( + x: card.minX, + y: card.maxY - Self.headerHeight, + width: card.width, + height: Self.headerHeight + ) + } + headerView.applyPanelWidth(card.width) + reportCardFrame() + } + } + + func updateWidth(_ width: CGFloat) { + guard panelWidth != width else { return } + panelWidth = width + widthReadout.setText(Self.widthLabel(width)) + if isDragging { + // The window drives iPad ticks: freeze on the first. + freezeIfNeeded() + } else { + headerView.applyPanelWidth(width) + } + viewIfLoaded?.setNeedsLayout() + } + + func updateOverhang(_ overhang: CGFloat) { + guard self.overhang != overhang else { return } + self.overhang = overhang + viewIfLoaded?.setNeedsLayout() + } + + func setPresented(_ presented: Bool) { + (paneController as? FileViewerPaneViewController)?.setActive(presented) + } + + func refreshHeader() { + guard isViewLoaded else { return } + if let pane = paneController as? FileViewerPaneViewController, + let configuration = pane.ornamentConfiguration { + headerView.apply(fileViewer: configuration, title: controller.tabLabel, width: panelWidth) + } else if let pane = paneController as? ViewportPaneViewController, + let configuration = pane.ornamentConfiguration { + headerView.apply(viewport: configuration, width: panelWidth) + } + } + + /// Unmounts pane-owned presentation/observation state but deliberately + /// does not call the auxiliary controller's `shutdown()`. The workspace + /// decides whether this was a close, replacement, or ↗ TAB re-key. + func prepareForRemoval() { + guard !preparedForRemoval else { return } + preparedForRemoval = true + dragSnapshot?.removeFromSuperview() + dragSnapshot = nil + (paneController as? ViewportPaneViewController)?.prepareForRemoval() + (paneController as? FileViewerPaneViewController)?.prepareForRemoval() + } + + // MARK: Live resize + + private func leadingSeamMoved(_ translation: CGFloat, phase: SidePanelResizePhase) { + if phase == .began { beginDrag() } + guard let start = dragStart else { return } + switch presentationStyle { + case .iPadOverlay: + // The window owns the container: report the wanted width, it + // clamps and lays out, and `updateWidth` brings the card along. + if phase != .began { resizeAction(start.width - translation, 0, phase) } + if phase == .ended { endDrag() } + case .visionOrnament: + applyLiveGeometry( + SidePanelWidth.visionGeometry( + draggingLeadingEdgeBy: translation, + from: start, + stripWidth: view.bounds.width + ), + phase: phase + ) + } + } + + private func trailingSeamMoved(_ translation: CGFloat, phase: SidePanelResizePhase) { + if phase == .began { beginDrag() } + guard let start = dragStart, presentationStyle == .visionOrnament else { return } + applyLiveGeometry( + SidePanelWidth.visionGeometry( + draggingTrailingEdgeBy: translation, + from: start, + stripWidth: view.bounds.width + ), + phase: phase + ) + } + + /// The card as the live strip can hold it: the stored geometry clamped + /// to the current bounds (a window narrower than the stored reach). + private var effectiveVisionGeometry: (width: CGFloat, overhang: CGFloat) { + SidePanelWidth.clampedVisionGeometry( + width: panelWidth, + overhang: overhang, + stripWidth: view.bounds.width + ) + } + + /// A clamped geometry from the model, straight onto the card. + private func applyLiveGeometry( + _ geometry: (width: CGFloat, overhang: CGFloat), + phase: SidePanelResizePhase + ) { + panelWidth = geometry.width + overhang = geometry.overhang + if phase == .ended { + endDrag() + resizeAction(panelWidth, overhang, .ended) + return + } + freezeIfNeeded() + // No synchronous layout per event: several gesture events inside one + // frame coalesce into one layout pass. + view.setNeedsLayout() + } + + private func beginDrag() { + // A drag starts from the card as shown, not from stored values a + // narrower window could not hold — else the first tick jumps. + let start = presentationStyle == .visionOrnament + ? effectiveVisionGeometry + : (width: panelWidth, overhang: 0) + dragStart = start + widthReadout.isHidden = presentationStyle != .iPadOverlay + leadingSeam.setHoverEnabled(false) + trailingSeam.setHoverEnabled(false) + reportCardFrame() + resizeAction(start.width, start.overhang, .began) + } + + /// First movement: picture the card once (blurred), hide the live views + /// and let the frame do the rest; a declined render gets a plain + /// snapshot under a live blur. + private func freezeIfNeeded() { + guard isDragging, dragSnapshot == nil else { return } + let picture: UIView + if let image = Self.blurredPicture(of: cardView) { + let imageView = UIImageView(image: image) + imageView.contentMode = .scaleToFill + picture = imageView + } else if let snapshot = cardView.snapshotView(afterScreenUpdates: false) + ?? cardView.snapshotView(afterScreenUpdates: true) { + let blur = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + blur.frame = snapshot.bounds + blur.autoresizingMask = [.flexibleWidth, .flexibleHeight] + snapshot.addSubview(blur) + picture = snapshot + } else { + return + } + picture.frame = cardView.bounds + picture.isUserInteractionEnabled = false + cardView.addSubview(picture) + headerView.isHidden = true + paneController.view.isHidden = true + dragSnapshot = picture + } + + private static let blurContext = CIContext(options: [.useSoftwareRenderer: false]) + + /// The card as one blurred bitmap at 1× — once per drag. + private static func blurredPicture(of view: UIView) -> UIImage? { + let bounds = view.bounds + guard bounds.width >= 1, bounds.height >= 1 else { return nil } + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.opaque = false + let rendered = UIGraphicsImageRenderer(bounds: bounds, format: format).image { _ in + view.drawHierarchy(in: bounds, afterScreenUpdates: false) + } + guard let input = CIImage(image: rendered) else { return nil } + let blurred = input + .clampedToExtent() + .applyingGaussianBlur(sigma: 7) + .cropped(to: input.extent) + guard let cgImage = blurContext.createCGImage(blurred, from: input.extent) else { return nil } + return UIImage(cgImage: cgImage, scale: 1, orientation: .up) + } + + /// Released: the live views return and reflow once at the new size; the + /// picture leaves a turn later so the reflow has painted. + private func thaw() { + guard let snapshot = dragSnapshot else { return } + dragSnapshot = nil + headerView.isHidden = false + paneController.view.isHidden = false + view.setNeedsLayout() + view.layoutIfNeeded() + reportCardFrame() + DispatchQueue.main.async { snapshot.removeFromSuperview() } + } + + private func endDrag() { + dragStart = nil + widthReadout.isHidden = true + leadingSeam.setHoverEnabled(true) + trailingSeam.setHoverEnabled(true) + thaw() + view.setNeedsLayout() + view.layoutIfNeeded() + reportCardFrame() + } + + // MARK: Testing + + var isFrozenForTesting: Bool { dragSnapshot != nil } + var cardFrameForTesting: CGRect { cardView.frame } + + func simulateLeadingDragForTesting(translation: CGFloat, phase: SidePanelResizePhase) { + leadingSeamMoved(translation, phase: phase) + } + + func simulateTrailingDragForTesting(translation: CGFloat, phase: SidePanelResizePhase) { + trailingSeamMoved(translation, phase: phase) + } + + private func reportCardFrame() { + guard presentationStyle == .visionOrnament else { return } + // While the finger moves the platter is down (an empty frame) — a + // system glass resized per tick showed on device; back on release. + onCardFrameChange?(isDragging ? .zero : cardView.frame) + } + + private func configureCard() { + cardView.accessibilityIdentifier = "terminal.sidePanel.card" + switch presentationStyle { + case .iPadOverlay: + cardView.backgroundColor = UIKitChassis.bezel + cardView.layer.cornerRadius = 3 + cardView.layer.cornerCurve = .continuous + cardView.clipsToBounds = true + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = 0.45 + view.layer.shadowRadius = 24 + view.layer.shadowOffset = CGSize(width: -8, height: 6) + case .visionOrnament: + // The card IS the slab: smoke under GLASS (the SwiftUI host adds + // the platter behind it), opaque chassis otherwise; the root is + // the transparent strip. + view.backgroundColor = .clear + view.layer.shadowOpacity = 0 + cardView.backgroundColor = GlassPrototype.windowGround + cardView.layer.borderWidth = 0 + cardView.layer.cornerRadius = 14 + cardView.layer.cornerCurve = .continuous + cardView.clipsToBounds = true + } + } + + private static func widthLabel(_ width: CGFloat) -> String { + "\(String(Int(width.rounded()))) pt" + } +} + +/// The visionOS strip: transparent and never the hit target itself, so a +/// pinch on its empty part falls through to the glass behind it. +@MainActor +final class SidePanelStripView: UIView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + return hit === self ? nil : hit + } +} + +/// Which edge of the card the row sits on: a header on iPad, the lower edge +/// on visionOS like every ornament row. Hairline and progress line face the +/// content. +enum SidePanelRowEdge { + case top + case bottom +} + +/// The one 30-point action row. It consumes the same value snapshots as the +/// visionOS auxiliary ornaments; its controls expand by measured priority and +/// `⋯` exists only for what did not fit. +@MainActor +final class SidePanelHeaderView: UIView { + /// ▤ groups in the order they earn a chip as the row widens. + private enum FileGroup: Equatable, CaseIterable { + case tree + case refresh + case sourceDiff + case select + case textScale + case host + } + + private struct FilePlan: Equatable { + var surfaced: [FileGroup] + var showsCrumb: Bool + var showsMore: Bool + } + + private enum RenderKey: Equatable { + case none + case file( + FileViewerOrnamentConfiguration.RenderKey, + title: String, + plan: FilePlan, + textScale: CGFloat + ) + case viewport(ViewportOrnamentConfiguration.RenderKey, showsSystem: Bool) + } + + private enum Content { + case none + case file(FileViewerOrnamentConfiguration, title: String) + case viewport(ViewportOrnamentConfiguration) + } + + private static let gap: CGFloat = 5 + private static let horizontalPadding: CGFloat = 15 + private static let minimumCrumbWidth: CGFloat = 48 + private static let minimumAddressWidth: CGFloat = 140 + /// Chip, label and badge widths depend on their text and the type scale + /// alone, and the row measures the same handful of strings every plan. + private static var measuredWidths: [String: CGFloat] = [:] + + let edge: SidePanelRowEdge + /// The panel's own controls — the same two for the row's whole life. + private let splitAction: () -> Void + private let closeAction: () -> Void + private var content = Content.none + private var panelWidth: CGFloat = 0 + private var renderedKey = RenderKey.none + private let row = UIStackView() + private let progressLine = UIView() + + init(edge: SidePanelRowEdge, split: @escaping () -> Void, close: @escaping () -> Void) { + self.edge = edge + splitAction = split + closeAction = close + super.init(frame: .zero) + backgroundColor = UIKitChassis.bezel + clipsToBounds = true + + row.axis = .horizontal + row.alignment = .center + row.spacing = Self.gap + addSubview(row) + row.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7), + row.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + let hairline = UIView() + hairline.backgroundColor = UIKitChassis.bezelHi + hairline.isUserInteractionEnabled = false + addSubview(hairline) + hairline.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hairline.leadingAnchor.constraint(equalTo: leadingAnchor), + hairline.trailingAnchor.constraint(equalTo: trailingAnchor), + edge == .top + ? hairline.bottomAnchor.constraint(equalTo: bottomAnchor) + : hairline.topAnchor.constraint(equalTo: topAnchor), + hairline.heightAnchor.constraint(equalToConstant: 1), + ]) + + progressLine.backgroundColor = TallyPalette.caution + progressLine.isAccessibilityElement = false + addSubview(progressLine) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("unused") } + + override func layoutSubviews() { + super.layoutSubviews() + let progress: CGFloat + if case .viewport(let configuration) = content, + configuration.key.isLoading { + progress = CGFloat(min(max(configuration.key.progress, 0), 1)) + } else { + progress = 0 + } + progressLine.isHidden = progress == 0 + progressLine.frame = CGRect( + x: 0, + y: edge == .top ? max(0, bounds.height - 2) : 0, + width: bounds.width * progress, + height: 2 + ) + } + + func apply(fileViewer: FileViewerOrnamentConfiguration, title: String, width: CGFloat) { + content = .file(fileViewer, title: title) + panelWidth = width + rebuild() + } + + func apply(viewport: ViewportOrnamentConfiguration, width: CGFloat) { + content = .viewport(viewport) + panelWidth = width + rebuild() + // The progress line is drawn by layout, never by a rebuild. + setNeedsLayout() + } + + func applyPanelWidth(_ width: CGFloat) { + guard abs(panelWidth - width) > 0.5 else { return } + panelWidth = width + rebuild() + } + + // MARK: Measuring — what fits is decided by the chips themselves + + private static func measured(_ key: String, _ make: () -> UIView) -> CGFloat { + if let cached = measuredWidths[key] { return cached } + let width = make().intrinsicContentSize.width + measuredWidths[key] = width + return width + } + + private static func chipWidth(_ caption: String, systemImage: String? = nil) -> CGFloat { + measured("chip|" + caption + "|" + (systemImage ?? "")) { + UIKitChassisChip(caption, systemImage: systemImage, accessibilityLabel: caption, action: {}) + } + } + + private static func labelWidth(_ text: String) -> CGFloat { + measured("label|" + text) { UIKitChassisLabel(text, size: 9) } + } + + private static func badgeWidth(_ text: String) -> CGFloat { + measured("badge|" + text) { FileViewerBadgeView(text) } + } + + private func filePlan(_ state: FileViewerOrnamentConfiguration.RenderKey, title: String) -> FilePlan { + let gap = Self.gap + var fixed = Self.labelWidth(title) + gap + if state.canGoBack || state.canGoForward { + fixed += Self.chipWidth("◂") + 2 + Self.chipWidth("▸") + gap + } + fixed += Self.chipWidth("↗ TAB") + gap + Self.chipWidth("✕") + + var candidates: [(FileGroup, CGFloat)] = [ + (.tree, Self.chipWidth(state.treeCaption) + gap), + (.refresh, Self.chipWidth("REFRESH") + gap), + ] + if state.showsSourceDiff { + candidates.append((.sourceDiff, Self.chipWidth("SOURCE") + 4 + Self.chipWidth("DIFF") + gap)) + } + if let caption = state.markdownSelectionCaption { + candidates.append((.select, Self.chipWidth(caption) + gap)) + } + candidates.append((.textScale, Self.chipWidth("A−") + 2 + Self.chipWidth("A+") + gap)) + candidates.append((.host, Self.badgeWidth(state.hostName.uppercased()) + gap)) + + var room = panelWidth - Self.horizontalPadding - fixed + let showsCrumb = room >= Self.minimumCrumbWidth + if showsCrumb { room -= Self.minimumCrumbWidth } + let everything = candidates.reduce(0) { $0 + $1.1 } + if everything <= room { + return FilePlan(surfaced: candidates.map(\.0), showsCrumb: showsCrumb, showsMore: false) + } + // Something folds, so `⋯` takes its place first; then the ladder + // fills strictly in order — never A− A+ while REFRESH is folded. + room -= Self.chipWidth("⋯") + gap + var surfaced: [FileGroup] = [] + for (group, width) in candidates { + guard width <= room else { break } + surfaced.append(group) + room -= width + } + return FilePlan(surfaced: surfaced, showsCrumb: showsCrumb, showsMore: true) + } + + /// Whether SYSTEM fits beside the viewport's fixed controls. + private func viewportShowsSystem(_ state: ViewportOrnamentConfiguration.RenderKey) -> Bool { + let gap = Self.gap + let fixed = Self.chipWidth("", systemImage: "chevron.left") + gap + + Self.chipWidth("", systemImage: "arrow.clockwise") + gap + + Self.minimumAddressWidth + gap + + Self.badgeWidth(state.railTag) + gap + + Self.chipWidth("↗ TAB") + gap + + Self.chipWidth("✕") + let room = panelWidth - Self.horizontalPadding - fixed + return Self.chipWidth("SYSTEM") + gap <= room + } + + // MARK: Building + + private func rebuild() { + let nextKey: RenderKey + let build: () -> Void + switch content { + case .none: + nextKey = .none + build = {} + case .file(let configuration, let title): + let plan = filePlan(configuration.key, title: title) + nextKey = .file( + configuration.key, + title: title, + plan: plan, + textScale: FileViewerTextScaleStore.shared.scale + ) + build = { self.buildFileRow(configuration, title: title, plan: plan) } + case .viewport(let configuration): + let showsSystem = viewportShowsSystem(configuration.key) + // Loading progress moves tens of times a page; layout draws the + // line from `content`, so it must not tear the row down. + var key = configuration.key + key.progress = 0 + nextKey = .viewport(key, showsSystem: showsSystem) + build = { self.buildViewportRow(configuration, showsSystem: showsSystem) } + } + guard renderedKey != nextKey else { return } + renderedKey = nextKey + UIView.performWithoutAnimation { + for arranged in row.arrangedSubviews { + row.removeArrangedSubview(arranged) + arranged.removeFromSuperview() + } + build() + setNeedsLayout() + layoutIfNeeded() + } + } + + private func buildFileRow( + _ configuration: FileViewerOrnamentConfiguration, + title: String, + plan: FilePlan + ) { + let state = configuration.key + // ✕ then ↗ TAB lead the row; the viewer's controls follow. + row.addArrangedSubview(closeChip(String(localized: "Close file viewer panel"))) + row.addArrangedSubview(splitChip(String(localized: "Move file viewer to a tab"))) + let identity = UIKitChassisLabel(title, size: 9) + identity.setContentHuggingPriority(.required, for: .horizontal) + identity.setContentCompressionResistancePriority(.required, for: .horizontal) + row.addArrangedSubview(identity) + + if plan.showsCrumb { + let crumb = UIKitChassisMonoLabel( + Self.directoryCrumb(state.path), + font: UIKitChassis.monoFont(9), + color: UIKitChassis.signal3 + ) + crumb.lineBreakMode = .byTruncatingMiddle + crumb.setContentHuggingPriority(.defaultLow, for: .horizontal) + crumb.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + row.addArrangedSubview(crumb) + } else { + let spacer = UIView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + row.addArrangedSubview(spacer) + } + + if state.canGoBack || state.canGoForward { + let back = UIKitChassisChip.rail( + "◂", + accessibilityLabel: String(localized: "Back"), + action: configuration.goBack + ) + back.isEnabled = state.canGoBack + let forward = UIKitChassisChip.rail( + "▸", + accessibilityLabel: String(localized: "Forward"), + action: configuration.goForward + ) + forward.isEnabled = state.canGoForward + row.addArrangedSubview(pair(back, forward, spacing: 2)) + } + + for group in plan.surfaced { + switch group { + case .tree: + let tree = UIKitChassisChip.rail( + state.treeCaption, + accessibilityLabel: state.treeCaption == "HIDE" + ? String(localized: "Hide the file tree") + : String(localized: "Show the file tree"), + action: configuration.toggleTree + ) + tree.accessibilityIdentifier = "sidePanel.file.tree" + row.addArrangedSubview(tree) + case .refresh: + let refresh = UIKitChassisChip.rail( + "REFRESH", + accessibilityLabel: String(localized: "Refresh file viewer"), + action: configuration.refresh + ) + refresh.accessibilityIdentifier = "sidePanel.file.refresh" + refresh.isEnabled = !state.isBusy + row.addArrangedSubview(refresh) + case .sourceDiff: + let source = UIKitChassisChip.rail( + "SOURCE", + prominent: state.sourceSelected, + accessibilityLabel: String(localized: "Show source"), + action: configuration.showSource + ) + let diff = UIKitChassisChip.rail( + "DIFF", + prominent: !state.sourceSelected, + accessibilityLabel: String(localized: "Show diff"), + action: configuration.showDiff + ) + let mode = pair(source, diff, spacing: 4) + mode.accessibilityLabel = String(localized: "Source or diff") + row.addArrangedSubview(mode) + case .select: + let caption = state.markdownSelectionCaption ?? "SELECT" + let select = UIKitChassisChip.rail( + caption, + prominent: caption == "DONE", + accessibilityLabel: caption == "DONE" + ? String(localized: "Back to rendered markdown") + : String(localized: "Select source text to copy"), + action: configuration.toggleMarkdownSelection + ) + select.accessibilityIdentifier = "sidePanel.file.select" + row.addArrangedSubview(select) + case .textScale: + let store = FileViewerTextScaleStore.shared + let smaller = UIKitChassisChip.rail( + "A−", + accessibilityLabel: String(localized: "Smaller text"), + action: { store.step(by: -1) } + ) + smaller.isEnabled = store.canStep(by: -1) + let larger = UIKitChassisChip.rail( + "A+", + accessibilityLabel: String(localized: "Larger text"), + action: { store.step(by: 1) } + ) + larger.isEnabled = store.canStep(by: 1) + row.addArrangedSubview(pair(smaller, larger, spacing: 2)) + case .host: + let host = FileViewerBadgeView(state.hostName.uppercased()) + host.accessibilityLabel = String(localized: "Files on \(state.hostName)") + host.setContentHuggingPriority(.required, for: .horizontal) + row.addArrangedSubview(host) + } + } + + if plan.showsMore { + let folded = FileGroup.allCases.filter { !plan.surfaced.contains($0) } + let more = ViewportMenuButton( + caption: "⋯", + accessibilityLabel: String(localized: "More file viewer actions"), + menu: fileMenu(configuration, folded: folded) + ) + more.accessibilityIdentifier = "sidePanel.file.more" + row.addArrangedSubview(more) + } + } + + /// Only what the row could not surface, in the same order. + private func fileMenu( + _ configuration: FileViewerOrnamentConfiguration, + folded: [FileGroup] + ) -> UIMenu { + let state = configuration.key + var actions: [UIMenuElement] = [] + for group in folded { + switch group { + case .tree: + actions.append(UIAction( + title: state.treeCaption == "HIDE" + ? String(localized: "Hide File Tree") + : String(localized: "Show File Tree") + ) { _ in configuration.toggleTree() }) + case .refresh: + actions.append(UIAction(title: String(localized: "Refresh")) { _ in + configuration.refresh() + }) + case .sourceDiff: + guard state.showsSourceDiff else { continue } + actions.append(UIAction( + title: String(localized: "Source"), + state: state.sourceSelected ? .on : .off + ) { _ in configuration.showSource() }) + actions.append(UIAction( + title: String(localized: "Diff"), + state: state.sourceSelected ? .off : .on + ) { _ in configuration.showDiff() }) + case .select: + guard let caption = state.markdownSelectionCaption else { continue } + actions.append(UIAction( + title: caption == "DONE" + ? String(localized: "Done Selecting") + : String(localized: "Select Source Text") + ) { _ in configuration.toggleMarkdownSelection() }) + case .textScale: + let scaleStore = FileViewerTextScaleStore.shared + actions.append(UIAction( + title: "A− · " + String(localized: "Smaller Text"), + attributes: scaleStore.canStep(by: -1) ? [] : .disabled + ) { _ in scaleStore.step(by: -1) }) + actions.append(UIAction( + title: "A+ · " + String(localized: "Larger Text"), + attributes: scaleStore.canStep(by: 1) ? [] : .disabled + ) { _ in scaleStore.step(by: 1) }) + case .host: + actions.append(UIAction( + title: state.hostName, + attributes: .disabled + ) { _ in }) + } + } + return UIMenu(children: actions) + } + + private func buildViewportRow( + _ configuration: ViewportOrnamentConfiguration, + showsSystem: Bool + ) { + let state = configuration.key + row.addArrangedSubview(closeChip(String(localized: "Close viewport panel"))) + row.addArrangedSubview(splitChip(String(localized: "Move viewport to a tab"))) + let back = UIKitChassisChip.rail( + "", + systemImage: "chevron.left", + accessibilityLabel: String(localized: "Back"), + action: configuration.goBack + ) + back.isEnabled = state.canGoBack + row.addArrangedSubview(back) + + let reload = UIKitChassisChip.rail( + "", + systemImage: state.isLoading ? "xmark" : "arrow.clockwise", + accessibilityLabel: state.isLoading + ? String(localized: "Stop loading") + : String(localized: "Reload"), + action: configuration.reloadOrStop + ) + row.addArrangedSubview(reload) + + let address = UIButton(type: .custom) + address.contentHorizontalAlignment = .leading + address.titleLabel?.numberOfLines = 1 + address.titleLabel?.lineBreakMode = .byTruncatingMiddle + address.setAttributedTitle( + ViewportPaneViewController.readoutText(state.displayURL), + for: .normal + ) + address.accessibilityLabel = state.displayURL.absoluteString + address.accessibilityHint = String(localized: "Edits the address") + address.accessibilityIdentifier = "sidePanel.viewport.address" + address.hoverStyle = UIHoverStyle(effect: .highlight, shape: .rect(cornerRadius: 2)) + address.addAction(UIAction { _ in configuration.editAddress() }, for: .touchUpInside) + address.menu = UIMenu(children: [ + UIAction( + title: String(localized: "Copy Address"), + image: UIImage(systemName: "doc.on.doc") + ) { _ in configuration.copyAddress() }, + UIAction( + title: String(localized: "Clear Browsing Data…"), + image: UIImage(systemName: "trash"), + attributes: .destructive + ) { _ in configuration.clearBrowsingData() }, + ]) + address.showsMenuAsPrimaryAction = false + address.setContentHuggingPriority(.defaultLow, for: .horizontal) + address.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + row.addArrangedSubview(address) + + let reach = ViewportBadgeView(state.railTag) + reach.accessibilityLabel = String(localized: "Reach: \(state.railTag)") + reach.setContentHuggingPriority(.required, for: .horizontal) + row.addArrangedSubview(reach) + + if showsSystem { + let system = UIKitChassisChip.rail( + "SYSTEM", + accessibilityLabel: String(localized: "Open in the system browser"), + action: configuration.openInSystemBrowser + ) + system.accessibilityIdentifier = "sidePanel.viewport.system" + row.addArrangedSubview(system) + } else { + let more = ViewportMenuButton( + caption: "⋯", + accessibilityLabel: String(localized: "More viewport actions"), + menu: UIMenu(children: [ + UIAction( + title: String(localized: "Open in System Browser"), + image: UIImage(systemName: "safari") + ) { _ in configuration.openInSystemBrowser() }, + ]) + ) + more.accessibilityIdentifier = "sidePanel.viewport.more" + row.addArrangedSubview(more) + } + } + + private func pair(_ first: UIView, _ second: UIView, spacing: CGFloat) -> UIStackView { + let stack = UIStackView(arrangedSubviews: [first, second]) + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = spacing + stack.isAccessibilityElement = false + return stack + } + + private func splitChip(_ accessibility: String) -> UIKitChassisChip { + let result = UIKitChassisChip.rail("↗ TAB", accessibilityLabel: accessibility, action: splitAction) + result.accessibilityIdentifier = "sidePanel.split" + return result + } + + private func closeChip(_ accessibility: String) -> UIKitChassisChip { + let result = UIKitChassisChip.rail( + "✕", + prominent: true, + accessibilityLabel: accessibility, + action: closeAction + ) + result.accessibilityIdentifier = "sidePanel.close" + return result + } + + private static func directoryCrumb(_ path: String) -> String { + FileTree.parent(of: path) ?? path + } +} + +/// A resize handle: a hit strip straddling one edge of the card, drawing +/// only the authored 3×36 pill (on the edge itself — `pillCenterX`). The +/// callback reports horizontal translation from gesture start; the owner +/// turns it into geometry, clamps, lays out and persists. +@MainActor +final class SidePanelSeamView: UIView { + var onResize: (CGFloat, SidePanelResizePhase) -> Void = { _, _ in } + /// Where the pill is drawn — the card's edge, not the strip's middle. + var pillCenterX: CGFloat? { + didSet { setNeedsLayout() } + } + + private let pill = UIView() + private var dragging = false + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + accessibilityIdentifier = "sidePanel.resizeSeam" + isAccessibilityElement = true + accessibilityLabel = String(localized: "Resize side panel") + accessibilityTraits = [.adjustable] + + pill.backgroundColor = UIKitChassis.signal3 + pill.layer.cornerRadius = 1.5 + pill.isUserInteractionEnabled = false + addSubview(pill) + setHoverEnabled(true) + + let pan = UIPanGestureRecognizer(target: self, action: #selector(dragged(_:))) + pan.maximumNumberOfTouches = 1 + addGestureRecognizer(pan) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("unused") } + + override func layoutSubviews() { + super.layoutSubviews() + pill.frame = CGRect( + x: (pillCenterX ?? bounds.midX) - 1.5, + y: (bounds.height - 36) / 2, + width: 3, + height: 36 + ) + } + + /// Off while a drag moves this view every tick — a moving hover region is + /// a system re-registration per frame. + func setHoverEnabled(_ enabled: Bool) { + hoverStyle = enabled + ? UIHoverStyle(effect: .highlight, shape: .rect(cornerRadius: 10)) + : nil + } + + override func accessibilityIncrement() { + onResize(-SidePanelWidth.accessibilityStep, .ended) + } + + override func accessibilityDecrement() { + onResize(SidePanelWidth.accessibilityStep, .ended) + } + + @objc private func dragged(_ recognizer: UIPanGestureRecognizer) { + let translation = recognizer.translation(in: self).x + switch recognizer.state { + case .began: + dragging = true + pill.backgroundColor = UIKitChassis.signal2 + onResize(0, .began) + case .changed: + onResize(translation, .changed) + case .ended, .cancelled, .failed: + dragging = false + pill.backgroundColor = UIKitChassis.signal3 + onResize(translation, .ended) + default: + break + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) + else { return } + pill.backgroundColor = dragging ? UIKitChassis.signal2 : UIKitChassis.signal3 + } +} diff --git a/Multiplex/Views/Terminal/TerminalTabStrip.swift b/Multiplex/Views/Terminal/TerminalTabStrip.swift index d0346353..adf67fa9 100644 --- a/Multiplex/Views/Terminal/TerminalTabStrip.swift +++ b/Multiplex/Views/Terminal/TerminalTabStrip.swift @@ -53,6 +53,7 @@ final class TerminalTabStripView: UIView, UIDropInteractionDelegate { var hostName: String? var isActive: Bool var isAuxiliary: Bool + var hasSidePanel: Bool var tallyState: TerminalTabTallyState var canSplit: Bool } @@ -476,6 +477,7 @@ final class TerminalTabStripView: UIView, UIDropInteractionDelegate { hostName: item.hostName, isActive: item.isActive, isAuxiliary: item.isAuxiliary, + hasSidePanel: item.hasSidePanel, tallyState: state, canSplit: canSplit(id: item.id) ) @@ -622,6 +624,7 @@ final class TerminalTabCell: UIView, private(set) var tallyState: TerminalTabTallyState private(set) var dotView: UIView? private(set) var sourceLabel: UIKitChassisLabel + private(set) var sidePanelMarkerLabel: UIKitChassisMonoLabel private let activate: () -> Void private let makeMenu: () -> UIMenu @@ -670,6 +673,12 @@ final class TerminalTabCell: UIView, size: 10, color: Self.ink(isActive: item.isActive) ) + sidePanelMarkerLabel = UIKitChassisMonoLabel( + "▸", + font: UIKitChassis.uiFont(10, weight: .semibold), + color: UIKitChassis.signal2 + ) + sidePanelMarkerLabel.isHidden = !item.hasSidePanel super.init(frame: .zero) backgroundColor = Self.ground(isActive: item.isActive) @@ -740,6 +749,7 @@ final class TerminalTabCell: UIView, } sourceLabel.isAccessibilityElement = false contentStack.addArrangedSubview(sourceLabel) + contentStack.addArrangedSubview(sidePanelMarkerLabel) } @available(*, unavailable) @@ -776,6 +786,7 @@ final class TerminalTabCell: UIView, // strip before that action has unwound. sourceLabel.setInk(Self.ink(isActive: item.isActive)) } + sidePanelMarkerLabel.isHidden = !item.hasSidePanel accessibilityLabel = Self.accessibilityLabel(for: item) if self.tallyState != tallyState { self.tallyState = tallyState @@ -791,7 +802,12 @@ final class TerminalTabCell: UIView, } private static func accessibilityLabel(for item: TerminalTabStrip.Item) -> String { - item.isActive + if item.hasSidePanel { + return item.isActive + ? String(localized: "\(item.title) tab, active, with side panel") + : String(localized: "\(item.title) tab, with side panel") + } + return item.isActive ? String(localized: "\(item.title) tab, active") : String(localized: "\(item.title) tab") } @@ -825,8 +841,11 @@ final class TerminalTabCell: UIView, let label = sourceLabel.intrinsicContentSize let lamp = dotView == nil ? 0 : Self.lampSize let lampWidth = dotView == nil ? 0 : Self.lampSize + Self.contentSpacing + let markerWidth = sidePanelMarkerLabel.isHidden + ? 0 + : sidePanelMarkerLabel.intrinsicContentSize.width + Self.contentSpacing return CGSize( - width: ceil(label.width + lampWidth + Self.horizontalInset * 2), + width: ceil(label.width + lampWidth + markerWidth + Self.horizontalInset * 2), height: ceil(max(label.height, lamp) + Self.verticalInset * 2) ) } @@ -987,6 +1006,8 @@ enum TerminalTabStrip { var isActive: Bool /// Auxiliary tabs carry their mark in the title and no tally dot. var isAuxiliary = false + /// A neutral ▸ follows a terminal title while its side panel exists. + var hasSidePanel = false } } diff --git a/Multiplex/Views/Terminal/TerminalVisionOrnaments.swift b/Multiplex/Views/Terminal/TerminalVisionOrnaments.swift index 6ec8cc4c..5bf99825 100644 --- a/Multiplex/Views/Terminal/TerminalVisionOrnaments.swift +++ b/Multiplex/Views/Terminal/TerminalVisionOrnaments.swift @@ -129,6 +129,23 @@ final class TerminalVisionOrnamentState { /// window: the composer reports, the window re-renders, `revision` /// bumps, and the mount re-asks the composer for its size. private(set) var talkbackController: TalkbackComposerViewController? + /// Active terminal tab's SIDECAR mount. It is independent of the bottom + /// console presentation: the active route remains a terminal while this + /// controller hangs from the scene's trailing edge. + private(set) var sidePanelController: SidePanelViewController? + /// The strip the side-panel mount is hosted at — twice the window's + /// width × its height; it follows a window resize, never a drag, so a + /// drag never changes the ornament's own size. + private(set) var sidePanelSize: CGSize = .zero + /// Where the card sits inside the strip, in the strip's coordinates — + /// reported by the controller on its content cadence (never per drag + /// tick); under GLASS the system glass platter follows it. + private(set) var sidePanelCardFrame: CGRect = .zero + + func setSidePanelCardFrame(_ frame: CGRect) { + guard sidePanelCardFrame != frame else { return } + sidePanelCardFrame = frame + } /// The helper controller is native UIKit, so its app-wide collapse choice /// is not observable by SwiftUI on its own. Mirror it into ornament state: /// this value is the animation trigger for the full rail ↔ corner dot @@ -162,13 +179,23 @@ final class TerminalVisionOrnamentState { umdController: UIViewController?, helperController: AgentHelperStripViewController?, talkbackController: TalkbackComposerViewController? = nil, + sidePanelController: SidePanelViewController? = nil, windowWidth: CGFloat, + windowHeight: CGFloat = 0, interfaceStyle: UIUserInterfaceStyle, forceRevision: Bool ) { self.interfaceStyle = interfaceStyle let nextHelper = isAuxiliary ? nil : helperController let nextTalkback = isAuxiliary ? nil : talkbackController + // The ornament hosts the whole strip; the card's place inside it is + // the controller's (and the store's) business, never the ornament's. + let nextSidePanelSize = sidePanelController == nil + ? CGSize.zero + : CGSize( + width: SidePanelWidth.visionStripWidth(windowWidth: windowWidth), + height: max(0, windowHeight) + ) let nextPresentation = TerminalVisionOrnamentPresentation.resolve( tabCount: tabCount, isAuxiliary: isAuxiliary, @@ -182,11 +209,15 @@ final class TerminalVisionOrnamentState { || self.umdController !== umdController || self.helperController !== nextHelper || self.talkbackController !== nextTalkback + || self.sidePanelController !== sidePanelController + || sidePanelSize != nextSidePanelSize || helperCollapsed != nextHelperCollapsed self.activeTerminalController = activeTerminalController self.umdController = umdController self.helperController = nextHelper self.talkbackController = nextTalkback + self.sidePanelController = sidePanelController + sidePanelSize = nextSidePanelSize helperCollapsed = nextHelperCollapsed presentation = nextPresentation refreshUMDContentSize() @@ -210,6 +241,9 @@ final class TerminalVisionOrnamentState { umdController = nil helperController = nil talkbackController = nil + sidePanelController = nil + sidePanelSize = .zero + sidePanelCardFrame = .zero helperCollapsed = false umdContentSize = .zero presentation = TerminalVisionOrnamentPresentation( @@ -254,7 +288,16 @@ final class TerminalVisionOrnamentCoordinator { ) { TerminalVisionBottomOrnament(state: state) } - owner.ornaments = [top, bottom] + // Centred on the trailing edge: the side-panel strip is as wide as the + // widest panel and straddles the glass, so the panel can hang outside + // it or reach in over the terminal — see TerminalVisionSidePanelOrnament. + let trailing = UIHostingOrnament( + sceneAnchor: UnitPoint.trailing, + contentAlignment: SwiftUI.Alignment.center + ) { + TerminalVisionSidePanelOrnament(state: state) + } + owner.ornaments = [top, bottom, trailing] installed = true } @@ -265,7 +308,9 @@ final class TerminalVisionOrnamentCoordinator { umdController: UIViewController?, helperController: AgentHelperStripViewController?, talkbackController: TalkbackComposerViewController? = nil, + sidePanelController: SidePanelViewController? = nil, windowWidth: CGFloat, + windowHeight: CGFloat = 0, interfaceStyle: UIUserInterfaceStyle, forceRevision: Bool = false ) { @@ -276,7 +321,9 @@ final class TerminalVisionOrnamentCoordinator { umdController: umdController, helperController: helperController, talkbackController: talkbackController, + sidePanelController: sidePanelController, windowWidth: windowWidth, + windowHeight: windowHeight, interfaceStyle: interfaceStyle, forceRevision: forceRevision ) @@ -313,6 +360,60 @@ private struct TerminalVisionTopOrnament: View { } } +/// ⚠ No SwiftUI animation or transition on this content, on purpose: an +/// animated removal of the slab (↗ TAB, ✕) left MRUIKit's coalescing +/// ornament updater holding the whole scene batch — the window kept +/// showing the old tab for ~6 s until an unrelated update flushed it +/// ("Animation settings for update are incompatible with pending changes", +/// visionOS 27 sim, 2026-08-21). Replacement still re-mounts through `.id`. +/// +/// Static on purpose: the ornament hosts ONE fixed strip and everything that +/// moves during a drag moves inside `SidePanelViewController`, in UIKit; +/// under GLASS the platter follows `sidePanelCardFrame`. +private struct TerminalVisionSidePanelOrnament: View { + @Bindable var state: TerminalVisionOrnamentState + + @ViewBuilder + var body: some View { + if let controller = state.sidePanelController { + let size = state.sidePanelSize + TerminalVisionControllerMount( + controller: controller, + sizing: .fixed(size), + revision: state.revision, + interfaceStyle: state.interfaceStyle + ) + .frame(width: size.width, height: size.height) + .id(ObjectIdentifier(controller)) + .background(alignment: .topLeading) { + TerminalVisionSidePanelGlass(state: state) + } + .fixedSize() + } + } +} + +/// PROTOTYPE(GLASS): the card paints its own smoke (UIKit, live); this is the +/// system glass platter under it, the one piece of the slab only SwiftUI can +/// draw. Empty in every other appearance, so nothing observes the card frame. +private struct TerminalVisionSidePanelGlass: View { + @Bindable var state: TerminalVisionOrnamentState + + @ViewBuilder + var body: some View { + if GlassPrototype.enabled && GlassSelectionState.shared.isGlass { + let frame = state.sidePanelCardFrame + if !frame.isEmpty { + Color.clear + .frame(width: frame.width, height: frame.height) + .glassBackgroundEffect(in: RoundedRectangle(cornerRadius: 14)) + .offset(x: frame.minX, y: frame.minY) + .allowsHitTesting(false) + } + } + } +} + private struct TerminalVisionBottomOrnament: View { @Bindable var state: TerminalVisionOrnamentState @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -985,6 +1086,9 @@ private enum TerminalVisionControllerSizing: Equatable { case terminalUMD case auxiliaryUMD case switchboardSlab + /// Exact SIDECAR geometry. A trailing ornament is re-laid out only when + /// the grip commits a rung; it never streams continuous proposals. + case fixed(CGSize) /// The Talkback slab: the composer's own arithmetic at the width the /// window handed it. case talkback @@ -1129,6 +1233,8 @@ private final class TerminalVisionControllerHost: UIViewController { if let slab = content as? ViewportSwitchboardSlabViewController { return slab.fittingContentSize(for: proposedWidth) } + case .fixed(let size): + return size case .talkback: if let composer = content as? TalkbackComposerViewController { return composer.fittingContentSize() diff --git a/Multiplex/Views/Terminal/TerminalWindow.swift b/Multiplex/Views/Terminal/TerminalWindow.swift index 8e313009..23f88c0b 100644 --- a/Multiplex/Views/Terminal/TerminalWindow.swift +++ b/Multiplex/Views/Terminal/TerminalWindow.swift @@ -29,6 +29,45 @@ extension Notification.Name { static let multiplexDebugFileViewerPlay = Notification.Name( "MultiplexDebugFileViewerPlay" ) + static let multiplexDebugSidePanelSplit = Notification.Name( + "MultiplexDebugSidePanelSplit" + ) + static let multiplexDebugSidePanelClose = Notification.Name( + "MultiplexDebugSidePanelClose" + ) + static let multiplexDebugSidePanelWidth = Notification.Name( + "MultiplexDebugSidePanelWidth" + ) +} + +/// SIDECAR's headless controls: ↗ TAB, ✕, and a persisted width cycle. +@MainActor +enum SidePanelDebugHook { + private static var installed = false + + static func install() { + guard !installed else { return } + installed = true + let center = NotificationCenter.default + var splitToken: Int32 = 0 + notify_register_dispatch( + "app.multiplexterm.multiplex.debug.sidepanelsplit", &splitToken, .main + ) { _ in + center.post(name: .multiplexDebugSidePanelSplit, object: nil) + } + var closeToken: Int32 = 0 + notify_register_dispatch( + "app.multiplexterm.multiplex.debug.sidepanelclose", &closeToken, .main + ) { _ in + center.post(name: .multiplexDebugSidePanelClose, object: nil) + } + var widthToken: Int32 = 0 + notify_register_dispatch( + "app.multiplexterm.multiplex.debug.sidepanelwidth", &widthToken, .main + ) { _ in + center.post(name: .multiplexDebugSidePanelWidth, object: nil) + } + } } /// `….debug.fileviewer` runs the focused window's + TAB ▸ File Viewer diff --git a/Multiplex/Views/Terminal/TerminalWindowUIKit.swift b/Multiplex/Views/Terminal/TerminalWindowUIKit.swift index d28e635c..5e10473c 100644 --- a/Multiplex/Views/Terminal/TerminalWindowUIKit.swift +++ b/Multiplex/Views/Terminal/TerminalWindowUIKit.swift @@ -241,6 +241,14 @@ final class TerminalWindowViewController: UIViewController, ) #endif private var paneControllers: [UUID: UIViewController] = [:] + /// View mounts are cached per host tab so switching away detaches the + /// panel from the window without cancelling a file load or WebKit page. + /// `TerminalWorkspace` still owns every auxiliary controller and decides + /// shutdown; this cache owns presentation only. + private var sidePanelViewControllers: [UUID: SidePanelViewController] = [:] + private var mountedSidePanelHostID: UUID? + private var transientSidePanelWidth: CGFloat? + private var sidePanelConversionScheduledFor: UUID? private var umdController: UIViewController? private var helperController: AgentHelperStripViewController? /// The active tab's open Talkback composer — one per window, re-pointed @@ -518,6 +526,12 @@ final class TerminalWindowViewController: UIViewController, unmount(controller) } paneControllers.removeAll() + for controller in sidePanelViewControllers.values { + controller.prepareForRemoval() + if controller.parent === self { unmount(controller) } + } + sidePanelViewControllers.removeAll() + mountedSidePanelHostID = nil replaceUMD(with: nil) replaceHelper(with: nil) passphrasePresenter.update( @@ -542,6 +556,41 @@ final class TerminalWindowViewController: UIViewController, private var activeTabHost: Host? { activeTab.flatMap { store.host(id: $0.hostID) } } + /// Which remembered width bucket this device uses. + private var sidePanelPlatform: SidePanelPlatform? { + #if os(visionOS) + .visionOS + #else + UIDevice.current.userInterfaceIdiom == .pad ? .iPad : nil + #endif + } + /// How this window presents a panel: the visionOS classic window hangs + /// it from a trailing ornament; the iPad and the visionOS Shell lay a + /// card over the pane. nil where no panel is ever admitted (iPhone). + private var sidePanelStyle: SidePanelPresentationStyle? { + #if os(visionOS) + shell == nil ? .visionOrnament : .iPadOverlay + #else + sidePanelPlatform == nil ? nil : .iPadOverlay + #endif + } + private var sidePanelEnvironmentOverride: String? { + #if DEBUG + ProcessInfo.processInfo.environment["MULTIPLEX_SIDE_PANEL"] + #else + nil + #endif + } + private func admitsSidePanel(anchor: TerminalRoute) -> Bool { + guard let style = sidePanelStyle else { return false } + return SidePanelPolicy.admitsPanel( + style: style, + paneWidth: rootView.paneContainer.bounds.width, + isCompactWidth: traitCollection.horizontalSizeClass == .compact, + anchorIsTerminal: !anchor.isAuxiliaryPane, + environmentOverride: sidePanelEnvironmentOverride + ) + } private var terminalFocusAllowed: Bool { shell?.terminalFocusAllowed ?? true } @@ -755,14 +804,26 @@ final class TerminalWindowViewController: UIViewController, _ = KeyboardLock.shared.isLocked _ = HardwareKeyboardMonitor.shared.isConnected #endif - for tab in route.tabs where tab.isAuxiliaryPane { - _ = workspace.auxiliaryController(for: tab.id)?.tabLabel + var showsFileViewer = false + for tab in route.tabs { + if tab.isAuxiliaryPane { + _ = workspace.auxiliaryController(for: tab.id)?.tabLabel + showsFileViewer = showsFileViewer || tab.isFileViewer + } else { + let panel = workspace.sidePanel(for: tab.id) + _ = panel?.tabLabel + showsFileViewer = showsFileViewer || panel is FileViewerController + } + } + if let platform = sidePanelPlatform { + _ = SidePanelWidthStore.shared.width(for: platform) + if sidePanelStyle == .visionOrnament { + _ = SidePanelWidthStore.shared.visionOverhang + } } // The rail's A− / A+ readout — a pinch inside the pane (or the // same chips in another window) has to reach it. - if route.tabs.contains(where: \.isFileViewer) { - _ = FileViewerTextScaleStore.shared.scale - } + if showsFileViewer { _ = FileViewerTextScaleStore.shared.scale } return detectedAgent } onChange: { [weak self] in Task { @MainActor [weak self] in @@ -809,6 +870,7 @@ final class TerminalWindowViewController: UIViewController, // Before the panes: their bottom inset reads the composer's height. renderTalkback() reconcilePaneControllers() + renderSidePanel() renderTabStrip() renderUMD() renderHelper() @@ -881,6 +943,15 @@ final class TerminalWindowViewController: UIViewController, }, adopt: { [weak self] tabs in self?.mutateRoute { $0.merge(tabs) } + }, + openFileViewer: { [weak self] hostID, target in + guard let self, + let activeTab, + !activeTab.isAuxiliaryPane, + activeTab.hostID == hostID + else { return false } + openFileViewer(target: target) + return true } )) } @@ -1110,7 +1181,10 @@ final class TerminalWindowViewController: UIViewController, present(alert, animated: true) } - func openFileViewer(target: TerminalPathTarget?) { + func openFileViewer( + target: TerminalPathTarget?, + allowsPanel: Bool = true + ) { guard let activeTab, let host = store.host(id: activeTab.hostID) else { return } let anchorID = activeTab.id let hostID = activeTab.hostID @@ -1126,6 +1200,23 @@ final class TerminalWindowViewController: UIViewController, ? nil : workspace.controller(for: anchorID)?.pathPressScreenCell let cwd = await workspace.controller(for: anchorID)? .paneWorkingDirectory(pressedAt: cell) + guard let anchor = route.tabs.first(where: { $0.id == anchorID }) else { return } + if allowsPanel, admitsSidePanel(anchor: anchor) { + workspace.openSidePanel( + hostTabID: anchorID, + controller: FileViewerController( + tabID: UUID(), + host: host, + startDirectory: cwd, + anchorSession: anchorSession, + anchorCell: cell, + target: target + ) + ) + renderNow() + return + } + let tab = TerminalRoute( hostID: hostID, mode: .fileViewer(path: target?.path ?? cwd ?? "~") @@ -1152,6 +1243,25 @@ final class TerminalWindowViewController: UIViewController, let sourceController = workspace.fileViewerController(for: sourceTabID), let host = store.host(id: sourceTab.hostID) else { return } + openFileInNewViewerTab( + row, + sourceController: sourceController, + hostID: sourceTab.hostID, + after: sourceTabID, + host: host + ) + } + + private func openFileInNewViewerTab( + _ row: FileTree.Row, + sourceController: FileViewerController, + hostID: UUID, + after anchorID: UUID, + host suppliedHost: Host? = nil + ) { + guard !row.entry.isDirectory, + let host = suppliedHost ?? store.host(id: hostID) + else { return } let path = row.entry.path // Tree entries are absolute remote paths. Carry that authority into // a fresh, in-memory viewer controller, registered before its route @@ -1163,7 +1273,7 @@ final class TerminalWindowViewController: UIViewController, line: nil ) let tab = TerminalRoute( - hostID: sourceTab.hostID, + hostID: hostID, mode: .fileViewer(path: path) ) workspace.openFileViewer( @@ -1174,18 +1284,30 @@ final class TerminalWindowViewController: UIViewController, target: target, targetPresentation: sourceController.selectionPresentation(for: row) ) - dock(tab, after: sourceTabID) + dock(tab, after: anchorID) } func openViewport(_ offer: ViewportOffer) { guard let activeTab else { return } - openViewport(offer, after: activeTab.id) + openViewport(offer, after: activeTab.id, allowsPanel: true) } - private func openViewport(_ offer: ViewportOffer, after anchorID: UUID) { + private func openViewport( + _ offer: ViewportOffer, + after anchorID: UUID, + allowsPanel: Bool = true + ) { guard let anchor = route.tabs.first(where: { $0.id == anchorID }), let host = store.host(id: anchor.hostID) else { return } + if allowsPanel, admitsSidePanel(anchor: anchor) { + workspace.openSidePanel( + hostTabID: anchorID, + controller: ViewportController(tabID: UUID(), offer: offer, host: host) + ) + renderNow() + return + } let tab = TerminalRoute( hostID: anchor.hostID, mode: .viewport(urlString: offer.url.absoluteString) @@ -1252,6 +1374,7 @@ final class TerminalWindowViewController: UIViewController, MessageJumpDebugHook.install() TerminalLinkDebugHook.install() FileViewerDebugHook.install() + SidePanelDebugHook.install() #endif guard let hostID = activeTab?.hostID, let host = store.host(id: hostID) @@ -1648,9 +1771,7 @@ extension TerminalWindowViewController { guard let pane else { continue } if paneControllers[tab.id] == nil { paneControllers[tab.id] = pane - addChild(pane) - rootView.paneContainer.addSubview(pane.view) - pane.didMove(toParent: self) + mount(pane, in: rootView.paneContainer) } pane.view.frame = rootView.paneContainer.bounds pane.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] @@ -1661,6 +1782,257 @@ extension TerminalWindowViewController { } } + private func renderSidePanel() { + let desiredHost = activeTab.flatMap { tab in + tab.isAuxiliaryPane ? nil : tab.id + } + // Inactive wrappers stay cached while their workspace controller is + // alive, so a hidden file keeps loading; a wrapper whose controller + // closed goes now. The desired host's REPLACED wrapper stays for the + // crossfade below and is released when it completes. + for (hostID, cached) in sidePanelViewControllers { + let live = workspace.sidePanel(for: hostID) + guard live !== cached.controller, !(hostID == desiredHost && live != nil) else { continue } + sidePanelViewControllers.removeValue(forKey: hostID) + cached.prepareForRemoval() + if cached.parent === self { unmount(cached) } + } + + guard let hostID = desiredHost, + let auxiliary = workspace.sidePanel(for: hostID), + let hostTab = route.tabs.first(where: { $0.id == hostID }) + else { + unmountCurrentSidePanel() + return + } + + let previousForHost = sidePanelViewControllers[hostID] + let panel: SidePanelViewController + if let previousForHost, previousForHost.controller === auxiliary { + panel = previousForHost + } else { + panel = makeSidePanelViewController(hostTab: hostTab, controller: auxiliary) + sidePanelViewControllers[hostID] = panel + } + let replaced = previousForHost !== panel ? previousForHost : nil + panel.setPresented(true) + + if sidePanelStyle == .visionOrnament { + // The ornament hosts the controller; the strip lays itself out. + panel.updateWidth(resolvedSidePanelWidth()) + panel.updateOverhang(SidePanelWidthStore.shared.visionOverhang) + panel.refreshHeader() + replaced?.prepareForRemoval() + mountedSidePanelHostID = hostID + panel.loadViewIfNeeded() + return + } + + // In-window: `layoutInWindowSidePanel` frames the container and + // hands the card its width on every layout pass. + panel.refreshHeader() + let oldMounted = mountedSidePanelHostID.flatMap { + $0 == hostID ? previousForHost : sidePanelViewControllers[$0] + } + let changes = { [self] in + if let oldMounted, oldMounted !== panel, oldMounted.parent === self { + oldMounted.setPresented(false) + unmount(oldMounted) + } + if panel.parent !== self { mount(panel, in: rootView.sidePanelContainer) } + panel.view.isHidden = false + mountedSidePanelHostID = hostID + rootView.sidePanelContainer.isHidden = false + layoutInWindowSidePanel() + } + if replaced != nil, animatesChrome { + UIView.transition( + with: rootView.sidePanelContainer, + duration: 0.3, + options: [.transitionCrossDissolve, .allowAnimatedContent], + animations: changes + ) { _ in replaced?.prepareForRemoval() } + } else { + changes() + replaced?.prepareForRemoval() + } + } + + private func makeSidePanelViewController( + hostTab: TerminalRoute, + controller: any AuxiliaryPaneController + ) -> SidePanelViewController { + let hostTabID = hostTab.id + let hostID = hostTab.hostID + // The row is UIKit inside the hosted controller: a rail change only + // refreshes it, never the window's ornaments. + let refreshHeader: () -> Void = { [weak self] in + self?.sidePanelViewControllers[hostTabID]?.refreshHeader() + } + let pane: UIViewController + if let viewport = controller as? ViewportController { + pane = ViewportPaneViewController( + controller: viewport, + contentSafeArea: .zero, + showsInWindowRail: false, + ornamentRailDidChange: refreshHeader, + borrowKeyboard: { [weak self] field in + let terminal = self?.workspace.controller(for: hostTabID)?.terminalView + TerminalFocusArbiter.lend(terminal, to: field) + }, + close: { [weak self] in + self?.closeSidePanel(hostTabID: hostTabID) + } + ) + } else if let fileViewer = controller as? FileViewerController { + pane = FileViewerPaneViewController( + controller: fileViewer, + contentSafeArea: .zero, + isActive: true, + showsInWindowRail: false, + ornamentRailDidChange: refreshHeader, + openInNewTab: { [weak self, weak fileViewer] row in + guard let fileViewer else { return } + self?.openFileInNewViewerTab( + row, + sourceController: fileViewer, + hostID: hostID, + after: hostTabID + ) + }, + openViewport: { [weak self] offer in + self?.openViewport( + offer, + after: hostTabID, + allowsPanel: false + ) + }, + close: { [weak self] in + self?.closeSidePanel(hostTabID: hostTabID) + } + ) + } else { + preconditionFailure("Unsupported side-panel controller") + } + + let style = sidePanelStyle ?? .iPadOverlay + let panel = SidePanelViewController( + controller: controller, + paneController: pane, + presentationStyle: style, + width: resolvedSidePanelWidth(), + overhang: style == .visionOrnament + ? SidePanelWidthStore.shared.visionOverhang : 0, + split: { [weak self] in + self?.splitSidePanelToTab(hostTabID: hostTabID) + }, + close: { [weak self] in + self?.closeSidePanel(hostTabID: hostTabID) + }, + resize: { [weak self] width, overhang, phase in + self?.sidePanelDidResize( + width: width, + overhang: overhang, + phase: phase, + hostTabID: hostTabID + ) + } + ) + #if os(visionOS) + if style == .visionOrnament { + panel.onCardFrameChange = { [weak self] frame in + self?.visionOrnaments.state.setSidePanelCardFrame(frame) + } + } + #endif + return panel + } + + private func unmountCurrentSidePanel() { + if sidePanelStyle == .visionOrnament { + if let hostID = mountedSidePanelHostID { + sidePanelViewControllers[hostID]?.setPresented(false) + } + mountedSidePanelHostID = nil + return + } + guard let hostID = mountedSidePanelHostID else { + rootView.sidePanelContainer.isHidden = true + return + } + if let panel = sidePanelViewControllers[hostID], panel.parent === self { + panel.setPresented(false) + unmount(panel) + } + mountedSidePanelHostID = nil + rootView.sidePanelContainer.isHidden = true + } + + /// The remembered width as this window can show it: an overlay clamps + /// to its pane here; the ornament's card clamps itself to the strip. + private func resolvedSidePanelWidth() -> CGFloat { + guard let platform = sidePanelPlatform, let style = sidePanelStyle else { return 0 } + if let transientSidePanelWidth { return transientSidePanelWidth } + let stored = SidePanelWidthStore.shared.width(for: platform) + switch style { + case .iPadOverlay: + return SidePanelWidth.clamped(stored, paneWidth: rootView.paneContainer.bounds.width) + case .visionOrnament: + return stored + } + } + + private func sidePanelDidResize( + width: CGFloat, + overhang: CGFloat, + phase: SidePanelResizePhase, + hostTabID: UUID + ) { + guard mountedSidePanelHostID == hostTabID, + let panel = sidePanelViewControllers[hostTabID] + else { return } + switch panel.presentationStyle { + case .visionOrnament: + // The strip laid itself out live; only the release persists. + guard phase == .ended else { return } + SidePanelWidthStore.shared.setVisionGeometry(width: width, overhang: overhang) + renderNow() + case .iPadOverlay: + // Only the overlay moves per tick; the rest of the chrome waits + // for the release's render. + let clamped = SidePanelWidth.clamped( + width, + paneWidth: rootView.paneContainer.bounds.width + ) + transientSidePanelWidth = clamped + layoutInWindowSidePanel() + guard phase == .ended else { return } + if let platform = sidePanelPlatform { + SidePanelWidthStore.shared.setWidth(clamped, for: platform) + } + transientSidePanelWidth = nil + renderNow() + } + } + + func closeSidePanel(hostTabID: UUID) { + workspace.closeSidePanel(hostTabID: hostTabID) + renderNow() + } + + func splitSidePanelToTab(hostTabID: UUID) { + guard let hostTab = route.tabs.first(where: { $0.id == hostTabID }), + !hostTab.isAuxiliaryPane, + let controller = workspace.detachSidePanel(hostTabID: hostTabID) + else { return } + + let auxiliaryTab = TerminalRoute(hostID: hostTab.hostID, mode: controller.routeMode) + // Register first: `dock` mutates the route synchronously and + // `syncTabs` strips controller-less auxiliaries by design. + workspace.adoptAuxiliary(controller, tabID: auxiliaryTab.id) + dock(auxiliaryTab, after: hostTabID) + } + private var showsInWindowAuxiliaryRail: Bool { #if os(visionOS) shell != nil @@ -1710,7 +2082,7 @@ extension TerminalWindowViewController { self?.openFileInNewViewerTab(row, after: tab.id) }, openViewport: { [weak self] offer in - self?.openViewport(offer, after: tab.id) + self?.openViewport(offer, after: tab.id, allowsPanel: false) }, close: { [weak self] in self?.closeTab(tab.id) } ) @@ -1744,7 +2116,7 @@ extension TerminalWindowViewController { self?.openFileInNewViewerTab(row, after: tab.id) }, openViewport: { [weak self] offer in - self?.openViewport(offer, after: tab.id) + self?.openViewport(offer, after: tab.id, allowsPanel: false) }, close: { [weak self] in self?.closeTab(tab.id) }, ornamentRailDidChange: { [weak self] in @@ -1790,6 +2162,14 @@ extension TerminalWindowViewController { (pane as? FileViewerPaneViewController)?.prepareForRemoval() } + private func mount(_ controller: UIViewController, in container: UIView) { + addChild(controller) + container.addSubview(controller.view) + controller.view.frame = container.bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + controller.didMove(toParent: self) + } + private func unmount(_ controller: UIViewController) { controller.willMove(toParent: nil) controller.view.removeFromSuperview() @@ -1834,7 +2214,9 @@ extension TerminalWindowViewController { hostName: multiHost ? store.host(id: tab.hostID)?.name : nil, controller: workspace.controller(for: tab.id), isActive: tab.id == activeTab?.id, - isAuxiliary: tab.isAuxiliaryPane + isAuxiliary: tab.isAuxiliaryPane, + hasSidePanel: !tab.isAuxiliaryPane + && workspace.sidePanel(for: tab.id) != nil ) } } @@ -2052,7 +2434,9 @@ extension TerminalWindowViewController { fontUp: { [weak self] in self?.changeFont(by: 1) }, newSession: { [weak self] in self?.openNewTab(launching: $0) }, newHerdrWorkspaceTab: { [weak self] in self?.openNewHerdrWorkspaceTab() }, - openFileViewer: { [weak self] in self?.openFileViewer(target: nil) }, + openFileViewer: { [weak self] in + self?.openFileViewer(target: nil, allowsPanel: false) + }, merge: { [weak self] in self?.merge($0) }, detach: { [weak self] in self?.detachActiveTab() }, closeSession: activeTabHasSession @@ -2362,6 +2746,9 @@ extension TerminalWindowViewController { private func updateVisionOrnaments(forceRevision: Bool = false) { guard shell == nil, !appLocked else { return } visionOrnaments.state.keyClusterContext.keyCommandPlan = keyCommandPlan + let sidePanel = mountedSidePanelHostID.flatMap { + sidePanelViewControllers[$0] + } visionOrnaments.update( tabCount: route.tabs.count, isAuxiliary: activeTab?.isAuxiliaryPane == true, @@ -2369,7 +2756,9 @@ extension TerminalWindowViewController { umdController: umdController, helperController: helperController, talkbackController: talkbackController, + sidePanelController: sidePanel, windowWidth: rootView.bounds.width, + windowHeight: rootView.bounds.height, interfaceStyle: themes.appearance.interfaceStyle, forceRevision: forceRevision ) @@ -2463,6 +2852,14 @@ extension TerminalWindowViewController { visionOrnaments.state.revision } + var visionSidePanelControllerForTesting: SidePanelViewController? { + visionOrnaments.state.sidePanelController + } + + var visionSidePanelSizeForTesting: CGSize { + visionOrnaments.state.sidePanelSize + } + var visionShellKeyClusterForTesting: TerminalKeyClusterGroupView { visionShellKeyCluster } @@ -2476,6 +2873,8 @@ extension TerminalWindowViewController { if shell == nil { refreshVisionHelperWidthIfNeeded() rootView.paneContainer.frame = bounds + rootView.sidePanelContainer.frame = .zero + rootView.sidePanelContainer.isHidden = true rootView.tabScrollView.frame = .zero rootView.umdContainer.frame = .zero rootView.helperContainer.frame = .zero @@ -2728,6 +3127,67 @@ extension TerminalWindowViewController { for pane in paneControllers.values { pane.view.frame = rootView.paneContainer.bounds } + layoutInWindowSidePanel() + } + + private func layoutInWindowSidePanel() { + guard let hostTabID = mountedSidePanelHostID, + let panel = sidePanelViewControllers[hostTabID], + panel.parent === self + else { + rootView.sidePanelContainer.frame = .zero + rootView.sidePanelContainer.isHidden = true + return + } + + // A pane that shrank below the overlay's floor (Stage Manager) moves + // the panel into a tab instead. + if let hostTab = route.tabs.first(where: { $0.id == hostTabID }), + !admitsSidePanel(anchor: hostTab) { + scheduleSidePanelConversion(hostTabID: hostTabID) + } + + let pane = rootView.paneContainer.frame + let width = resolvedSidePanelWidth() + let bottom: CGFloat = { + #if os(visionOS) + pane.maxY - (workspace.controller(for: hostTabID)?.keyboardObstruction ?? 0) + #else + // `paneContainer` owns both screen and key rail. The authored + // SIDECAR rectangle is the screen portion of that pane: use the + // same obstruction-lifted rail seam as Talkback so the full-width + // keys remain visible and interactive beneath the overlay. + paneChromeBottom + #endif + }() + let frame = SidePanelWidth.overlayContainerFrame( + pane: pane, + bottom: bottom, + width: width, + leadingOverhang: panel.leadingOverhang + ) + rootView.sidePanelContainer.frame = frame + rootView.sidePanelContainer.isHidden = frame.isEmpty + panel.view.frame = rootView.sidePanelContainer.bounds + panel.updateWidth(width) + } + + private func scheduleSidePanelConversion(hostTabID: UUID) { + guard sidePanelConversionScheduledFor != hostTabID else { return } + sidePanelConversionScheduledFor = hostTabID + Task { @MainActor [weak self] in + guard let self else { return } + defer { + if sidePanelConversionScheduledFor == hostTabID { + sidePanelConversionScheduledFor = nil + } + } + guard workspace.sidePanel(for: hostTabID) != nil, + let hostTab = route.tabs.first(where: { $0.id == hostTabID }), + !admitsSidePanel(anchor: hostTab) + else { return } + splitSidePanelToTab(hostTabID: hostTabID) + } } } @@ -2914,6 +3374,15 @@ extension TerminalWindowViewController { } observe(.multiplexDebugFileViewer) { [weak self] in self?.debugOpenFileViewer() } observe(.multiplexDebugPathView) { [weak self] in self?.debugViewPendingPath() } + observe(.multiplexDebugSidePanelSplit) { [weak self] in + self?.debugSplitSidePanel() + } + observe(.multiplexDebugSidePanelClose) { [weak self] in + self?.debugCloseSidePanel() + } + observe(.multiplexDebugSidePanelWidth) { [weak self] in + self?.debugCycleSidePanelWidth() + } observe(.multiplexDebugFileViewerRepoDiff) { [weak self] in guard let self, let activeTab, @@ -3006,7 +3475,7 @@ extension TerminalWindowViewController { private func debugOpenFileViewer() { guard ownsFocusedTerminal else { return } - openFileViewer(target: nil) + openFileViewer(target: nil, allowsPanel: false) } private func debugViewPendingPath() { @@ -3019,6 +3488,42 @@ extension TerminalWindowViewController { dismissPresentedFeature() } + /// The active terminal's open panel, while this window owns the focused + /// terminal — what the DEBUG panel hooks act on. + private var debugSidePanelHostTabID: UUID? { + guard ownsFocusedTerminal, + let hostTabID = activeTab?.id, + workspace.sidePanel(for: hostTabID) != nil + else { return nil } + return hostTabID + } + + private func debugSplitSidePanel() { + guard let hostTabID = debugSidePanelHostTabID else { return } + splitSidePanelToTab(hostTabID: hostTabID) + } + + private func debugCloseSidePanel() { + guard let hostTabID = debugSidePanelHostTabID else { return } + closeSidePanel(hostTabID: hostTabID) + } + + private func debugCycleSidePanelWidth() { + guard debugSidePanelHostTabID != nil, let platform = sidePanelPlatform else { return } + let store = SidePanelWidthStore.shared + switch platform { + case .iPad: + store.setWidth(store.width(for: .iPad) < 460 ? 560 : 360, for: .iPad) + case .visionOS: + let width = store.width(for: .visionOS) + let next = SidePanelWidth.visionWidthCycle.first { $0 > width } + ?? SidePanelWidth.visionWidthCycle[0] + store.setWidth(next, for: .visionOS) + } + transientSidePanelWidth = nil + renderNow() + } + private func debugLogLinkRegions() { guard ownsFocusedTerminal, let view = activeController?.terminalView else { return } let logger = Logger( @@ -3086,6 +3591,9 @@ final class TerminalWindowUIKitRootView: UIView { static let tabRailVerticalInset: CGFloat = 6 let paneContainer = UIView() + /// iPad/Shell SIDECAR mount: a sibling over the pane, deliberately not + /// part of its layout or SwiftTerm's size proposal. + let sidePanelContainer = UIView() let tabScrollView = TerminalTabScrollView() let umdContainer = UIView() let helperContainer = UIView() @@ -3113,6 +3621,9 @@ final class TerminalWindowUIKitRootView: UIView { tabDivider.backgroundColor = UIKitChassis.bezelHi umdContainer.backgroundColor = .clear helperContainer.backgroundColor = .clear + sidePanelContainer.backgroundColor = .clear + sidePanelContainer.clipsToBounds = false + sidePanelContainer.isHidden = true talkbackContainer.backgroundColor = .clear talkbackContainer.clipsToBounds = true bottomSafeAreaBackfill.backgroundColor = UIKitChassis.bezel @@ -3125,10 +3636,12 @@ final class TerminalWindowUIKitRootView: UIView { addSubview(tabScrollView) addSubview(tabDivider) addSubview(umdContainer) + addSubview(sidePanelContainer) addSubview(talkbackContainer) addSubview(helperContainer) addSubview(bottomSafeAreaBackfill) paneContainer.accessibilityIdentifier = "terminalWindow.panes" + sidePanelContainer.accessibilityIdentifier = "terminalWindow.sidePanel" tabScrollView.accessibilityIdentifier = "terminalWindow.tabs" umdContainer.accessibilityIdentifier = "terminalWindow.umd" helperContainer.accessibilityIdentifier = "terminalWindow.helpers" diff --git a/Multiplex/Views/Terminal/ViewportPane.swift b/Multiplex/Views/Terminal/ViewportPane.swift index f39fc3f4..33dbb968 100644 --- a/Multiplex/Views/Terminal/ViewportPane.swift +++ b/Multiplex/Views/Terminal/ViewportPane.swift @@ -62,6 +62,9 @@ final class ViewportPaneViewController: UIViewController, private let controller: ViewportController private var contentSafeArea: UIEdgeInsets private var closeAction: () -> Void + /// A side-panel address field borrows the host terminal's keyboard across + /// ornament windows. Ordinary viewport tabs leave this as a no-op. + private var borrowKeyboard: (UIView) -> Void private var observationGeneration = 0 private var state: ViewportPaneObservedState? private var failureIdentity: String? @@ -95,12 +98,14 @@ final class ViewportPaneViewController: UIViewController, contentSafeArea: UIEdgeInsets = .zero, showsInWindowRail: Bool = true, ornamentRailDidChange: @escaping () -> Void = {}, + borrowKeyboard: @escaping (UIView) -> Void = { _ in }, close: @escaping () -> Void ) { self.controller = controller self.contentSafeArea = contentSafeArea self.showsInWindowRail = showsInWindowRail self.ornamentRailDidChange = ornamentRailDidChange + self.borrowKeyboard = borrowKeyboard closeAction = close super.init(nibName: nil, bundle: nil) } @@ -130,10 +135,12 @@ final class ViewportPaneViewController: UIViewController, func update( contentSafeArea: UIEdgeInsets, close: @escaping () -> Void, - ornamentRailDidChange: @escaping () -> Void = {} + ornamentRailDidChange: @escaping () -> Void = {}, + borrowKeyboard: @escaping (UIView) -> Void = { _ in } ) { closeAction = close self.ornamentRailDidChange = ornamentRailDidChange + self.borrowKeyboard = borrowKeyboard if self.contentSafeArea != contentSafeArea { self.contentSafeArea = contentSafeArea if isViewLoaded { updateRailInsets() } @@ -447,6 +454,7 @@ final class ViewportPaneViewController: UIViewController, if let addressEditor { addressEditor.setText(controller.displayURL.absoluteString) addressEditor.setRejected(false) + borrowKeyboard(addressEditor.textField) addressEditor.textField.becomeFirstResponder() return } @@ -466,6 +474,7 @@ final class ViewportPaneViewController: UIViewController, editor.topAnchor.constraint(equalTo: pageArea.topAnchor, constant: 12), ]) pageArea.bringSubviewToFront(editor) + borrowKeyboard(editor.textField) DispatchQueue.main.async { [weak editor] in editor?.textField.becomeFirstResponder() } } diff --git a/MultiplexTests/ExternalActionTests.swift b/MultiplexTests/ExternalActionTests.swift index 0ed7cbfd..d3cbe2ce 100644 --- a/MultiplexTests/ExternalActionTests.swift +++ b/MultiplexTests/ExternalActionTests.swift @@ -287,6 +287,50 @@ final class ExternalActionTests: XCTestCase { workspace.closeTab(tab.id) } + @MainActor + func testOpenFileTargetsAnExistingActiveTerminalBeforeOpeningAFreshRoute() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let host = Host(name: "devbox", hostname: "127.0.0.1", username: "tester") + let store = HostStore(directory: directory, knownMirroredIDs: []) + store.add(host) + let workspace = TerminalWorkspace() + let terminal = TerminalRoute(hostID: host.id, mode: .shell) + var targeted: TerminalPathTarget? + workspace.registerWindow(.init( + id: UUID(), + tabs: [terminal], + label: "terminal", + reveal: { _ in }, + surrender: { [] }, + adopt: { _ in }, + openFileViewer: { _, target in + targeted = target + return true + } + )) + var opened: TerminalWindowRoute? + let context = ExternalActionRouter.Context( + store: store, + hub: ConnectionHub(), + workspace: workspace, + open: { opened = $0 }, + presentAgentPrompt: { _ in }, + presentFailure: { _ in }, + presentConfirmation: { _ in } + ) + + await ExternalActionPerformer.perform( + .openFile(host: .id(host.id), path: "/srv/project/App.swift", line: 42), + context: context + ) + + XCTAssertEqual(try XCTUnwrap(targeted).path, "/srv/project/App.swift") + XCTAssertEqual(targeted?.line, 42) + XCTAssertNil(opened, "the target window decides panel versus tab") + } + // MARK: Session pick func testMostRecentSessionPicksNewestCreated() { diff --git a/MultiplexTests/SidePanelTests.swift b/MultiplexTests/SidePanelTests.swift new file mode 100644 index 00000000..09712ff4 --- /dev/null +++ b/MultiplexTests/SidePanelTests.swift @@ -0,0 +1,177 @@ +import XCTest +@testable import Multiplex + +@MainActor +final class SidePanelTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUpWithError() throws { + suiteName = "SidePanelTests-\(UUID().uuidString)" + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + } + + func testWidthDefaultsAndClamps() { + XCTAssertEqual(SidePanelWidth.defaultWidth(for: .iPad), 440) + XCTAssertEqual(SidePanelWidth.defaultWidth(for: .visionOS), 520) + XCTAssertEqual(SidePanelWidth.defaultVisionOverhang, 440, "the right edge starts 440 pt past the glass") + XCTAssertEqual(SidePanelWidth.maxVisionOverhang, 800, "360 pt of outward travel from the default") + XCTAssertEqual( + SidePanelWidth.visionStripWidth(windowWidth: 1_400), + 2_800, + "twice the window, so the strip starts at the window's leading edge" + ) + XCTAssertEqual(SidePanelWidth.visionStripWidth(windowWidth: .nan), 0) + + // Overlay: both bounds, and a pane too small for card + terminal. + XCTAssertEqual(SidePanelWidth.clamped(200, paneWidth: 1_000), 320) + XCTAssertEqual(SidePanelWidth.clamped(900, paneWidth: 1_000), 680) + XCTAssertEqual(SidePanelWidth.clamped(440, paneWidth: 500), 320) + XCTAssertEqual(SidePanelWidth.clamped(.nan, paneWidth: 1_000), 320) + // visionOS: continuous inside a sanity range. + XCTAssertEqual(SidePanelWidth.clampedVision(473), 473, "no snapping — a release lands where it was") + XCTAssertEqual(SidePanelWidth.clampedVision(-1_000), 360) + XCTAssertEqual(SidePanelWidth.clampedVision(10_000), 2_400) + XCTAssertEqual(SidePanelWidth.clampedVision(.nan), 520) + // As stored: iPad keeps only the floor (the pane clamps at use). + XCTAssertEqual(SidePanelWidth.clampedStored(100, for: .iPad), 320) + XCTAssertEqual(SidePanelWidth.clampedStored(900, for: .iPad), 900) + XCTAssertEqual(SidePanelWidth.clampedStored(5_000, for: .visionOS), 2_400) + + let pane = CGRect(x: 0, y: 100, width: 1_000, height: 700) + XCTAssertEqual( + SidePanelWidth.overlayContainerFrame(pane: pane, bottom: 760, width: 440, leadingOverhang: 12), + CGRect(x: 540, y: 108, width: 452, height: 644), + "8 pt inside the pane, the seam's overhang past the card's leading edge" + ) + } + + func testVisionGeometryKeepsTheCardInsideTheStripAndOffTheTerminalReserve() { + // A 1400-pt window: strip 2800, glass edge at 1400. + let strip = SidePanelWidth.visionStripWidth(windowWidth: 1_400) + let standard = SidePanelWidth.clampedVisionGeometry(width: 520, overhang: 440, stripWidth: strip) + XCTAssertEqual(standard.width, 520) + XCTAssertEqual(standard.overhang, 440) + let reach = SidePanelWidth.clampedVisionGeometry(width: 3_000, overhang: 440, stripWidth: strip) + XCTAssertEqual(reach.width, 1_520, "the left edge stops 320 pt into the window: the terminal reserve") + XCTAssertEqual(reach.overhang, 440) + let farOut = SidePanelWidth.clampedVisionGeometry(width: 520, overhang: 1_000, stripWidth: strip) + XCTAssertEqual(farOut.overhang, 800, "the right edge never hangs past maxVisionOverhang") + XCTAssertEqual(farOut.width, 520) + let pulledIn = SidePanelWidth.clampedVisionGeometry(width: 520, overhang: -2_000, stripWidth: strip) + XCTAssertEqual(pulledIn.overhang, -720, "the right edge stops at reserve + minimum width into the window") + XCTAssertEqual(pulledIn.width, 360) + let broken = SidePanelWidth.clampedVisionGeometry(width: .nan, overhang: .nan, stripWidth: strip) + XCTAssertEqual(broken.width, 520) + XCTAssertEqual(broken.overhang, 440) + + // A narrower window clamps the same stored geometry live. + let narrow = SidePanelWidth.visionStripWidth(windowWidth: 600) + let squeezed = SidePanelWidth.clampedVisionGeometry(width: 1_520, overhang: 440, stripWidth: narrow) + XCTAssertEqual(squeezed.width, 720) + XCTAssertEqual(squeezed.overhang, 440) + let tiny = SidePanelWidth.clampedVisionGeometry(width: 520, overhang: 440, stripWidth: 600) + XCTAssertEqual(tiny.overhang, 300, "inside the strip") + XCTAssertEqual(tiny.width, 360, "the minimum width wins over the reserve in a window too narrow for both") + } + + func testVisionDragGeometryMovesOneEdgeAtATime() { + // (520, 440) in a 1400-pt window: strip 2800, right edge at 1840. + let strip = SidePanelWidth.visionStripWidth(windowWidth: 1_400) + let start = (width: CGFloat(520), overhang: CGFloat(440)) + let leading = SidePanelWidth.visionGeometry(draggingLeadingEdgeBy: -40, from: start, stripWidth: strip) + XCTAssertEqual(leading.width, 560) + XCTAssertEqual(leading.overhang, 440, "the right edge stays put") + let reach = SidePanelWidth.visionGeometry(draggingLeadingEdgeBy: -2_000, from: start, stripWidth: strip) + XCTAssertEqual(reach.width, 1_520, "the left edge stops 320 pt into the window") + let narrower = SidePanelWidth.visionGeometry(draggingTrailingEdgeBy: -100, from: start, stripWidth: strip) + XCTAssertEqual(narrower.width, 420) + XCTAssertEqual(narrower.overhang, 340, "the left edge stays put") + let out = SidePanelWidth.visionGeometry(draggingTrailingEdgeBy: 1_000, from: start, stripWidth: strip) + XCTAssertEqual(out.overhang, 800, "360 pt of outward travel, then the edge stops") + XCTAssertEqual(out.width, 880) + let minimum = SidePanelWidth.visionGeometry(draggingTrailingEdgeBy: -400, from: start, stripWidth: strip) + XCTAssertEqual(minimum.width, 360) + XCTAssertEqual(minimum.overhang, 280, "inward past the minimum width the edge stops too") + } + + func testPolicyMatrix() { + struct Case { + let style: SidePanelPresentationStyle + let width: CGFloat + let compact: Bool + let terminal: Bool + let override: String? + let expected: Bool + } + let cases = [ + Case(style: .iPadOverlay, width: 659.999, compact: false, + terminal: true, override: nil, expected: false), + Case(style: .iPadOverlay, width: 660, compact: false, + terminal: true, override: nil, expected: true), + Case(style: .iPadOverlay, width: 1_024, compact: true, + terminal: true, override: nil, expected: false), + Case(style: .iPadOverlay, width: 1_024, compact: false, + terminal: false, override: nil, expected: false), + Case(style: .visionOrnament, width: 0, compact: true, + terminal: true, override: nil, expected: true), + Case(style: .visionOrnament, width: 2_000, compact: false, + terminal: false, override: nil, expected: false), + Case(style: .iPadOverlay, width: 1_024, compact: false, + terminal: true, override: "0", expected: false), + Case(style: .visionOrnament, width: 2_000, compact: false, + terminal: true, override: "0", expected: false), + Case(style: .iPadOverlay, width: 1_024, compact: false, + terminal: true, override: "1", expected: true), + Case(style: .iPadOverlay, width: 600, compact: false, + terminal: true, override: "unknown", expected: false), + ] + + for testCase in cases { + let override = testCase.override ?? "nil" + XCTAssertEqual( + SidePanelPolicy.admitsPanel( + style: testCase.style, + paneWidth: testCase.width, + isCompactWidth: testCase.compact, + anchorIsTerminal: testCase.terminal, + environmentOverride: testCase.override + ), + testCase.expected, + "\(testCase.style), width=\(testCase.width), compact=\(testCase.compact), " + + "terminal=\(testCase.terminal), override=\(override)" + ) + } + } + + func testWidthStorePersistsPerPlatformAndGlassRelative() { + let store = SidePanelWidthStore(defaults: defaults) + XCTAssertEqual(store.width(for: .iPad), 440) + XCTAssertEqual(store.width(for: .visionOS), 520) + XCTAssertEqual(store.visionOverhang, 440, "the right edge starts 440 pt past the glass") + + store.setWidth(560, for: .iPad) + store.setVisionGeometry(width: 500, overhang: 1_000) + XCTAssertEqual(store.visionOverhang, 800, "never farther out than the right handle can go") + store.setWidth(900, for: .visionOS) + XCTAssertEqual(store.width(for: .visionOS), 900, "a width-only write keeps the right edge") + + let restored = SidePanelWidthStore(defaults: defaults) + XCTAssertEqual(restored.width(for: .iPad), 560) + XCTAssertEqual(restored.width(for: .visionOS), 900) + XCTAssertEqual(restored.visionOverhang, 800) + + // Junk on disk normalizes on read. + defaults.set(100, forKey: "MultiplexSidePanelWidth.iPad") + defaults.set(5_000, forKey: "MultiplexSidePanelWidth.visionOS") + defaults.set(5_000, forKey: "MultiplexSidePanelOverhang.visionOS") + let normalized = SidePanelWidthStore(defaults: defaults) + XCTAssertEqual(normalized.width(for: .iPad), 320) + XCTAssertEqual(normalized.width(for: .visionOS), 2_400) + XCTAssertEqual(normalized.visionOverhang, 800) + } +} diff --git a/MultiplexTests/SidePanelViewControllerTests.swift b/MultiplexTests/SidePanelViewControllerTests.swift new file mode 100644 index 00000000..a0da37b0 --- /dev/null +++ b/MultiplexTests/SidePanelViewControllerTests.swift @@ -0,0 +1,135 @@ +import UIKit +import XCTest +@testable import Multiplex + +/// The side panel's resize mechanics: a drag freezes the card into a picture +/// and moves only frames; the release thaws it and reports the geometry. +@MainActor +final class SidePanelViewControllerTests: XCTestCase { + private final class FakeAuxiliaryController: AuxiliaryPaneController { + var tabLabel = "⌗ test" + var routeMode: TerminalRoute.Mode { .viewport(urlString: "https://example.com") } + func shutdown() {} + } + + private var window: UIWindow! + + override func setUp() { + window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1_200, height: 700)) + window.isHidden = false + } + + override func tearDown() { + window.isHidden = true + window = nil + } + + /// The visionOS strip for a 1400-pt window: 2800 wide, glass edge at 1400. + private let visionWindowWidth: CGFloat = 1_400 + + private func makePanel( + style: SidePanelPresentationStyle, + width: CGFloat, + overhang: CGFloat = SidePanelWidth.defaultVisionOverhang, + stripWindowWidth: CGFloat = 1_400, + resize: @escaping (CGFloat, CGFloat, SidePanelResizePhase) -> Void = { _, _, _ in } + ) -> SidePanelViewController { + let panel = SidePanelViewController( + controller: FakeAuxiliaryController(), + paneController: UIViewController(), + presentationStyle: style, + width: width, + overhang: overhang, + split: {}, + close: {}, + resize: resize + ) + window.rootViewController = panel + panel.view.frame = style == .visionOrnament + ? CGRect( + x: 0, + y: 0, + width: SidePanelWidth.visionStripWidth(windowWidth: stripWindowWidth), + height: 700 + ) + : CGRect(x: 0, y: 0, width: width + SidePanelViewController.seamOverhang, height: 700) + panel.view.layoutIfNeeded() + return panel + } + + func testVisionDragsFreezeTheCardMoveOneEdgeAndThawOnRelease() { + var ended: (width: CGFloat, overhang: CGFloat)? + let panel = makePanel(style: .visionOrnament, width: 520) { width, overhang, phase in + if phase == .ended { ended = (width, overhang) } + } + let rightEdge = visionWindowWidth + SidePanelWidth.defaultVisionOverhang + XCTAssertEqual(panel.cardFrameForTesting.maxX, rightEdge, "440 pt past the glass") + XCTAssertFalse(panel.isFrozenForTesting) + + // Left handle: the right edge stays. + panel.simulateLeadingDragForTesting(translation: 0, phase: .began) + panel.simulateLeadingDragForTesting(translation: -40, phase: .changed) + // Events only mark layout dirty; the frame lands on the next pass. + panel.view.layoutIfNeeded() + XCTAssertTrue(panel.isFrozenForTesting, "movement pictures the card instead of reflowing it") + XCTAssertEqual(panel.cardFrameForTesting.maxX, rightEdge) + XCTAssertEqual(panel.cardFrameForTesting.minX, rightEdge - 560) + panel.simulateLeadingDragForTesting(translation: -40, phase: .ended) + XCTAssertFalse(panel.isFrozenForTesting, "release brings the live views back") + XCTAssertEqual(ended?.width, 560) + XCTAssertEqual(ended?.overhang, 440) + + // Right handle: the left edge stays. + let leftEdge = rightEdge - 560 + panel.simulateTrailingDragForTesting(translation: 0, phase: .began) + panel.simulateTrailingDragForTesting(translation: 200, phase: .changed) + panel.view.layoutIfNeeded() + XCTAssertTrue(panel.isFrozenForTesting) + XCTAssertEqual(panel.cardFrameForTesting.minX, leftEdge) + XCTAssertEqual(panel.cardFrameForTesting.maxX, rightEdge + 200) + panel.simulateTrailingDragForTesting(translation: 200, phase: .ended) + XCTAssertFalse(panel.isFrozenForTesting) + XCTAssertEqual(ended?.width, 760) + XCTAssertEqual(ended?.overhang, 640) + + // Stored for a wide window, shown in a 600-pt one (strip 1200): the + // card clamps live, keeps the stored values, and a drag starts from + // what it shows — never from the stored reach. + var began: (width: CGFloat, overhang: CGFloat)? + let squeezed = makePanel( + style: .visionOrnament, + width: 1_520, + overhang: 440, + stripWindowWidth: 600 + ) { width, overhang, phase in + if phase == .began { began = (width, overhang) } + } + XCTAssertEqual(squeezed.cardFrameForTesting.minX, SidePanelWidth.minimumTerminalWidth) + XCTAssertEqual(squeezed.cardFrameForTesting.width, 720) + XCTAssertEqual(squeezed.panelWidth, 1_520, "kept for a window that grows back") + squeezed.simulateTrailingDragForTesting(translation: 0, phase: .began) + XCTAssertEqual(began?.width, 720) + XCTAssertEqual(began?.overhang, 440) + squeezed.simulateTrailingDragForTesting(translation: 10, phase: .ended) + XCTAssertEqual(squeezed.panelWidth, 730) + XCTAssertEqual(squeezed.overhang, 450) + } + + func testIPadWindowDrivenTicksFreezeUntilRelease() { + var reported: [(CGFloat, SidePanelResizePhase)] = [] + let panel = makePanel(style: .iPadOverlay, width: 440) { width, _, phase in + reported.append((width, phase)) + } + panel.simulateLeadingDragForTesting(translation: 0, phase: .began) + panel.simulateLeadingDragForTesting(translation: -30, phase: .changed) + // The window clamps and answers with the card width, as it does live. + panel.updateWidth(470) + XCTAssertTrue(panel.isFrozenForTesting) + XCTAssertEqual(reported.last?.0, 470) + XCTAssertEqual(reported.last?.1, .changed) + + panel.simulateLeadingDragForTesting(translation: -30, phase: .ended) + XCTAssertFalse(panel.isFrozenForTesting) + XCTAssertEqual(reported.last?.1, .ended) + } +} diff --git a/MultiplexTests/TerminalWindowUIKitTests.swift b/MultiplexTests/TerminalWindowUIKitTests.swift index 159fa2c1..9c8c28e7 100644 --- a/MultiplexTests/TerminalWindowUIKitTests.swift +++ b/MultiplexTests/TerminalWindowUIKitTests.swift @@ -359,6 +359,288 @@ final class TerminalWindowUIKitTests: XCTestCase { } #if !os(visionOS) + func testWideIPadPathViewMountsAPanelWithoutRelayingOutThePane() async throws { + let host = Host( + name: "devbox", + hostname: "127.0.0.1", + port: 1, + username: "dev" + ) + let terminal = TerminalRoute(hostID: host.id, mode: .shell) + let fixture = makeFixture( + route: TerminalWindowRoute(tab: terminal), + hosts: [host] + ) + defer { + fixture.controller.prepareForRemoval() + fixture.store.remove(host) + } + fixture.controller.loadViewIfNeeded() + fixture.controller.view.frame = CGRect(x: 0, y: 0, width: 900, height: 700) + fixture.controller.view.layoutIfNeeded() + let root = try XCTUnwrap( + fixture.controller.view as? TerminalWindowUIKitRootView + ) + let paneFrame = root.paneContainer.frame + let target = try XCTUnwrap(TerminalPathTarget.resolveExplicit( + "/workspace/README.md", + line: 12 + )) + + fixture.controller.openFileViewer(target: target) + for _ in 0..<8 { await Task.yield() } + fixture.controller.view.layoutIfNeeded() + + XCTAssertEqual(fixture.controller.route.tabs, [terminal]) + XCTAssertNotNil(fixture.workspace.sidePanel(for: terminal.id)) + XCTAssertFalse(root.sidePanelContainer.isHidden) + XCTAssertFalse(root.sidePanelContainer.subviews.isEmpty) + XCTAssertEqual(root.paneContainer.frame, paneFrame, "the overlay never resizes SwiftTerm") + XCTAssertEqual(root.sidePanelContainer.frame.maxX, paneFrame.maxX - 8, accuracy: 0.5) + XCTAssertEqual(root.sidePanelContainer.frame.minY, paneFrame.minY + 8, accuracy: 0.5) + XCTAssertEqual( + root.sidePanelContainer.frame.maxY, + paneFrame.maxY - TerminalKeyBar.barHeight(spendsBottomStrip: true) - 8, + accuracy: 0.5, + "the panel overlays the screen, never the full-width key rail" + ) + + let strip = try XCTUnwrap(descendant( + of: TerminalTabStripView.self, + in: fixture.controller.view + )) + XCTAssertFalse(try XCTUnwrap(strip.cells.first).sidePanelMarkerLabel.isHidden) + } + + func testExplicitFileViewerTabDoorStaysATabAtWideWidth() async { + let host = Host(name: "devbox", hostname: "127.0.0.1", port: 1, username: "dev") + let terminal = TerminalRoute(hostID: host.id, mode: .shell) + let fixture = makeFixture( + route: TerminalWindowRoute(tab: terminal), + hosts: [host] + ) + defer { + fixture.controller.prepareForRemoval() + fixture.store.remove(host) + } + fixture.controller.loadViewIfNeeded() + fixture.controller.view.frame = CGRect(x: 0, y: 0, width: 900, height: 700) + fixture.controller.view.layoutIfNeeded() + + fixture.controller.openFileViewer(target: nil, allowsPanel: false) + for _ in 0..<8 { await Task.yield() } + + XCTAssertEqual(fixture.controller.route.tabs.count, 2) + XCTAssertTrue(fixture.controller.route.activeTab?.isFileViewer == true) + XCTAssertNil(fixture.workspace.sidePanel(for: terminal.id)) + } + + func testNarrowIPadAndDebugOverrideKeepTheTabRoad() { + let host = Host(name: "devbox", hostname: "127.0.0.1", port: 1, username: "dev") + let offer = ViewportOffer( + url: URL(string: "https://example.com")!, + reach: .internet, + viaHostName: nil + ) + + do { + let terminal = TerminalRoute(hostID: host.id, mode: .shell) + let fixture = makeFixture( + route: TerminalWindowRoute(tab: terminal), + hosts: [host] + ) + fixture.controller.loadViewIfNeeded() + fixture.controller.view.frame = CGRect(x: 0, y: 0, width: 640, height: 700) + fixture.controller.view.layoutIfNeeded() + fixture.controller.openViewport(offer) + + XCTAssertEqual(fixture.controller.route.tabs.count, 2) + XCTAssertTrue(fixture.controller.route.activeTab?.isViewport == true) + XCTAssertNil(fixture.workspace.sidePanel(for: terminal.id)) + fixture.controller.prepareForRemoval() + fixture.store.remove(host) + } + + setenv("MULTIPLEX_SIDE_PANEL", "0", 1) + defer { unsetenv("MULTIPLEX_SIDE_PANEL") } + let terminal = TerminalRoute(hostID: host.id, mode: .shell) + let fixture = makeFixture( + route: TerminalWindowRoute(tab: terminal), + hosts: [host] + ) + fixture.controller.loadViewIfNeeded() + fixture.controller.view.frame = CGRect(x: 0, y: 0, width: 900, height: 700) + fixture.controller.view.layoutIfNeeded() + fixture.controller.openViewport(offer) + + XCTAssertEqual(fixture.controller.route.tabs.count, 2) + XCTAssertTrue(fixture.controller.route.activeTab?.isViewport == true) + XCTAssertNil(fixture.workspace.sidePanel(for: terminal.id)) + fixture.controller.prepareForRemoval() + fixture.store.remove(host) + } + + func testSidePanelSplitRekeysTheSameControllerIntoTheActiveRightHandTab() throws { + let host = Host(name: "devbox", hostname: "127.0.0.1", port: 1, username: "dev") + let terminal = TerminalRoute(hostID: host.id, mode: .shell) + let fixture = makeFixture( + route: TerminalWindowRoute(tab: terminal), + hosts: [host] + ) + defer { + fixture.controller.prepareForRemoval() + fixture.store.remove(host) + } + fixture.controller.loadViewIfNeeded() + fixture.controller.view.frame = CGRect(x: 0, y: 0, width: 900, height: 700) + fixture.controller.view.layoutIfNeeded() + fixture.controller.openViewport(ViewportOffer( + url: URL(string: "https://example.com/docs")!, + reach: .internet, + viaHostName: nil + )) + let panel = try XCTUnwrap(fixture.workspace.sidePanel(for: terminal.id)) + + fixture.controller.splitSidePanelToTab(hostTabID: terminal.id) + + XCTAssertNil(fixture.workspace.sidePanel(for: terminal.id)) + XCTAssertEqual(fixture.controller.route.tabs.count, 2) + XCTAssertEqual(fixture.controller.route.tabs[0], terminal) + let auxiliary = try XCTUnwrap(fixture.controller.route.activeTab) + XCTAssertTrue(auxiliary.isViewport) + XCTAssertEqual(fixture.controller.route.tabs[1].id, auxiliary.id) + XCTAssertTrue(fixture.workspace.auxiliaryController(for: auxiliary.id) === panel) + } + + func testStageManagerShrinkConvertsPanelToTabWithoutLosingController() async throws { + let host = Host(name: "devbox", hostname: "127.0.0.1", port: 1, username: "dev") + let terminal = TerminalRoute(hostID: host.id, mode: .shell) + let fixture = makeFixture( + route: TerminalWindowRoute(tab: terminal), + hosts: [host] + ) + defer { + fixture.controller.prepareForRemoval() + fixture.store.remove(host) + } + fixture.controller.loadViewIfNeeded() + fixture.controller.view.frame = CGRect(x: 0, y: 0, width: 900, height: 700) + fixture.controller.view.layoutIfNeeded() + fixture.controller.openViewport(ViewportOffer( + url: URL(string: "https://example.com")!, + reach: .internet, + viaHostName: nil + )) + let panel = try XCTUnwrap(fixture.workspace.sidePanel(for: terminal.id)) + + fixture.controller.view.frame.size.width = 640 + fixture.controller.view.setNeedsLayout() + fixture.controller.view.layoutIfNeeded() + for _ in 0..<8 { await Task.yield() } + + XCTAssertNil(fixture.workspace.sidePanel(for: terminal.id)) + let auxiliary = try XCTUnwrap(fixture.controller.route.activeTab) + XCTAssertTrue(auxiliary.isViewport) + XCTAssertTrue(fixture.workspace.auxiliaryController(for: auxiliary.id) === panel) + } + + func testSummonFromAnAuxiliaryAnchorAlwaysDocksAnotherTab() { + let host = Host(name: "devbox", hostname: "127.0.0.1", port: 1, username: "dev") + let firstOffer = ViewportOffer( + url: URL(string: "https://example.com/one")!, + reach: .internet, + viaHostName: nil + ) + let first = TerminalRoute( + hostID: host.id, + mode: .viewport(urlString: firstOffer.url.absoluteString) + ) + let workspace = TerminalWorkspace() + workspace.openViewport(tab: first, offer: firstOffer, host: host) + let fixture = makeFixture( + route: TerminalWindowRoute(tab: first), + hosts: [host], + workspace: workspace + ) + defer { + fixture.controller.prepareForRemoval() + fixture.store.remove(host) + } + fixture.controller.loadViewIfNeeded() + fixture.controller.view.frame = CGRect(x: 0, y: 0, width: 900, height: 700) + fixture.controller.view.layoutIfNeeded() + + fixture.controller.openViewport(ViewportOffer( + url: URL(string: "https://example.com/two")!, + reach: .internet, + viaHostName: nil + )) + + XCTAssertEqual(fixture.controller.route.tabs.count, 2) + XCTAssertTrue(fixture.controller.route.tabs.allSatisfy(\.isViewport)) + XCTAssertNil(workspace.sidePanel(for: first.id)) + } + + func testMergeCarriesPanelByHostTabIDAndReceivingWindowMountsIt() throws { + let workspace = TerminalWorkspace() + let sourceHost = Host( + name: "source", + hostname: "127.0.0.1", + port: 1, + username: "dev" + ) + let targetHost = Host( + name: "target", + hostname: "127.0.0.1", + port: 1, + username: "dev" + ) + let sourceTab = TerminalRoute(hostID: sourceHost.id, mode: .shell) + let targetTab = TerminalRoute(hostID: targetHost.id, mode: .shell) + let source = makeFixture( + route: TerminalWindowRoute(tab: sourceTab), + hosts: [sourceHost, targetHost], + workspace: workspace + ) + let target = makeFixture( + route: TerminalWindowRoute(tab: targetTab), + hosts: [sourceHost, targetHost], + workspace: workspace + ) + defer { + source.controller.prepareForRemoval() + target.controller.prepareForRemoval() + source.store.remove(sourceHost) + source.store.remove(targetHost) + target.store.remove(sourceHost) + target.store.remove(targetHost) + } + for fixture in [source, target] { + fixture.controller.loadViewIfNeeded() + fixture.controller.view.frame = CGRect(x: 0, y: 0, width: 900, height: 700) + fixture.controller.view.layoutIfNeeded() + } + source.controller.openViewport(ViewportOffer( + url: URL(string: "https://example.com")!, + reach: .internet, + viaHostName: nil + )) + let panel = try XCTUnwrap(workspace.sidePanel(for: sourceTab.id)) + + let moved = workspace.surrenderTabs(of: source.controller.route.id) + let receiver = try XCTUnwrap( + workspace.windows.first(where: { $0.id == target.controller.route.id }) + ) + receiver.adopt(moved) + target.controller.activate(sourceTab.id) + target.controller.view.layoutIfNeeded() + + XCTAssertTrue(workspace.sidePanel(for: sourceTab.id) === panel) + XCTAssertFalse(try XCTUnwrap( + target.controller.view as? TerminalWindowUIKitRootView + ).sidePanelContainer.subviews.isEmpty) + } + func testPaneBoundsStopAboveBottomStripOnlyWhenTheRailDoesNotSpendIt() { let bounds = CGRect(x: 0, y: 0, width: 950, height: 1_200) let safeArea = UIEdgeInsets(top: 0, left: 0, bottom: 21, right: 0) @@ -802,6 +1084,55 @@ final class TerminalWindowUIKitTests: XCTestCase { ) } + func testVisionSidePanelUsesThirdOrnamentWithoutChangingConsolePresentation() throws { + let host = Host( + name: "devbox", + hostname: "127.0.0.1", + port: 1, + username: "dev" + ) + let terminal = TerminalRoute(hostID: host.id, mode: .shell) + let fixture = makeFixture( + route: TerminalWindowRoute(tab: terminal), + hosts: [host] + ) + defer { + fixture.controller.prepareForRemoval() + fixture.store.remove(host) + } + fixture.controller.loadViewIfNeeded() + fixture.controller.view.frame = CGRect(x: 0, y: 0, width: 900, height: 620) + fixture.controller.view.layoutIfNeeded() + let before = fixture.controller.visionOrnamentPresentationForTesting + + fixture.controller.openViewport(ViewportOffer( + url: URL(string: "https://example.com")!, + reach: .internet, + viaHostName: nil + )) + fixture.controller.view.layoutIfNeeded() + + XCTAssertEqual(fixture.controller.ornaments.count, 3) + XCTAssertEqual(fixture.controller.route.tabs, [terminal]) + XCTAssertNotNil(fixture.controller.visionSidePanelControllerForTesting) + XCTAssertEqual(fixture.controller.visionSidePanelSizeForTesting.height, 620) + XCTAssertEqual( + fixture.controller.visionSidePanelSizeForTesting.width, + 1_800, + "the strip is twice the window, centred on its trailing edge" + ) + XCTAssertEqual( + fixture.controller.visionOrnamentPresentationForTesting.bottom, + before.bottom, + "SIDECAR leaves the terminal console row in place" + ) + + fixture.controller.closeSidePanel(hostTabID: terminal.id) + XCTAssertNil(fixture.controller.visionSidePanelControllerForTesting) + XCTAssertEqual(fixture.controller.visionSidePanelSizeForTesting, .zero) + XCTAssertEqual(fixture.controller.visionOrnamentPresentationForTesting.bottom, before.bottom) + } + func testClassicVisionWindowMovesNativeChromeOutOfWindowAndIntoOrnamentState() { let first = TerminalRoute(hostID: UUID(), mode: .attach(sessionName: "main")) let second = TerminalRoute(hostID: UUID(), mode: .attach(sessionName: "scratch")) @@ -939,6 +1270,7 @@ final class TerminalWindowUIKitTests: XCTestCase { route: TerminalWindowRoute, shell: TerminalWindowShellConfiguration? = nil, hosts: [Host] = [], + workspace suppliedWorkspace: TerminalWorkspace? = nil, performSceneIntent: @escaping (SceneWindowRouting.Intent) -> Void = { _ in }, routeChanged: @escaping (TerminalWindowRoute) -> Void = { _ in } ) -> Fixture { @@ -948,7 +1280,7 @@ final class TerminalWindowUIKitTests: XCTestCase { let defaults = UserDefaults( suiteName: "app.multiplexterm.multiplex.tests.terminal-window.\(suffix)" )! - let workspace = TerminalWorkspace() + let workspace = suppliedWorkspace ?? TerminalWorkspace() let store = HostStore(directory: directory, knownMirroredIDs: []) for host in hosts { store.add(host) } let dependencies = TerminalWindowDependencies( diff --git a/MultiplexTests/TerminalWorkspaceSidePanelTests.swift b/MultiplexTests/TerminalWorkspaceSidePanelTests.swift new file mode 100644 index 00000000..402be666 --- /dev/null +++ b/MultiplexTests/TerminalWorkspaceSidePanelTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import Multiplex + +@MainActor +final class TerminalWorkspaceSidePanelTests: XCTestCase { + private final class FakeAuxiliaryController: AuxiliaryPaneController { + var tabLabel = "⌗ test" + var routeMode: TerminalRoute.Mode { .viewport(urlString: "https://example.com") } + private(set) var shutdownCount = 0 + + func shutdown() { + shutdownCount += 1 + } + } + + func testPanelLifecycleUnderItsHostTab() { + let workspace = TerminalWorkspace() + let hostTabID = UUID() + let first = FakeAuxiliaryController() + let second = FakeAuxiliaryController() + + workspace.openSidePanel(hostTabID: hostTabID, controller: first) + XCTAssertTrue(workspace.sidePanel(for: hostTabID) === first) + XCTAssertEqual(first.shutdownCount, 0) + + workspace.openSidePanel(hostTabID: hostTabID, controller: second) + XCTAssertEqual(first.shutdownCount, 1, "a second open replaces and shuts down the first") + XCTAssertTrue(workspace.sidePanel(for: hostTabID) === second) + + workspace.closeSidePanel(hostTabID: hostTabID) + XCTAssertNil(workspace.sidePanel(for: hostTabID)) + XCTAssertEqual(second.shutdownCount, 1) + + let third = FakeAuxiliaryController() + workspace.openSidePanel(hostTabID: hostTabID, controller: third) + workspace.closeTab(hostTabID) + XCTAssertNil(workspace.sidePanel(for: hostTabID), "closing the host tab closes its panel") + XCTAssertEqual(third.shutdownCount, 1) + } + + func testDetachAndAdoptMovesTheSameLiveControllerWithoutShutdown() throws { + let workspace = TerminalWorkspace() + let hostTabID = UUID() + let auxiliaryTabID = UUID() + let controller = FakeAuxiliaryController() + workspace.openSidePanel(hostTabID: hostTabID, controller: controller) + + let detached = try XCTUnwrap(workspace.detachSidePanel(hostTabID: hostTabID)) + workspace.adoptAuxiliary(detached, tabID: auxiliaryTabID) + + XCTAssertNil(workspace.sidePanel(for: hostTabID)) + XCTAssertTrue(workspace.auxiliaryController(for: auxiliaryTabID) === controller) + XCTAssertEqual(controller.shutdownCount, 0) + } +} diff --git a/MultiplexTests/ViewportPaneUIKitTests.swift b/MultiplexTests/ViewportPaneUIKitTests.swift index 86b13905..5efb058d 100644 --- a/MultiplexTests/ViewportPaneUIKitTests.swift +++ b/MultiplexTests/ViewportPaneUIKitTests.swift @@ -129,6 +129,28 @@ final class ViewportPaneUIKitTests: XCTestCase { XCTAssertEqual(cancelCount, 1) } + func testSidePanelAddressEditorBorrowsKeyboardBeforeTakingResponder() throws { + let controller = makeViewportController() + controller.stopLoading() + weak var borrowed: UIView? + let pane = ViewportPaneViewController( + controller: controller, + showsInWindowRail: false, + borrowKeyboard: { borrowed = $0 }, + close: {} + ) + pane.loadViewIfNeeded() + defer { + pane.prepareForRemoval() + controller.shutdown() + } + + pane.beginEditingAddress() + + let field = try XCTUnwrap(pane.addressEditor?.textField) + XCTAssertTrue(borrowed === field) + } + func testInteractiveLinkDismissClearsThePendingURL() throws { let controller = makeViewportController() controller.stopLoading() diff --git a/README.md b/README.md index 40aa358f..f76adc59 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A spatial SSH terminal for remote **tmux** — visionOS first, with iPadOS and i - **Real terminal windows:** spatial scenes on visionOS, Stage Manager on iPad, and an adaptive iPhone shell. Tabs can move or merge without reconnecting. - **Agent awareness:** detects Claude Code, Codex, Pi, and Grok Build; surfaces questions, permissions, and completed turns on the wall and through notifications. - **Purpose-built input:** keyboard-focus arbitration, key rail with hold-CTRL Key Commands (saved chords and text macros), IME, dictation, remote scrolling, tmux Copy Mode, and text selection. -- **More than a shell:** docked web and file viewers, SFTP uploads, optional clean-room mosh, widgets, Shortcuts, deep links, and custom themes. +- **More than a shell:** confirmed web links and remote paths open in a floating side panel beside the live terminal on iPad and Vision Pro (or move into a tab), alongside SFTP uploads, optional clean-room mosh, widgets, Shortcuts, deep links, and custom themes. Some features require Multiplex Pro; see [`docs/store-metadata.md`](docs/store-metadata.md). This repository contains the complete app. diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md index ddb1aa25..7ead0ac3 100644 --- a/docs/agents/architecture.md +++ b/docs/agents/architecture.md @@ -71,7 +71,8 @@ UIKit scene runtime (MultiplexSceneDelegate + UIKitSceneRootViewController; WidgetPalette, and ACCENTED draws white-with-opacity only — the channel the tint remap preserves. TerminalWorkspace tab controllers keyed by tab id — merge/split move - tabs across windows, shells stay live + tabs across windows, shells stay live; a second map keys + one summoned side-panel controller by its HOST tab id ViewportController one per ⌗ viewport tab; owns the WKWebView so moves re-parent the live page; in-memory only FileViewerController one per ▤ file-viewer tab; dials its OWN lazy @@ -79,6 +80,9 @@ UIKit scene runtime (MultiplexSceneDelegate + UIKitSceneRootViewController; isAuxiliaryPane rules with the viewport); its Document owns the PDF / audio clip, so a moved tab keeps page and position + SidePanelViewController one active-window mount around an existing ▤/⌗ + pane: 30 pt header + iPad seam or visionOS trailing slab; + presentation only, never controller lifetime TerminalSessionController one per tab; input pump + TerminalView TalkbackDraft the tab's chat-style message box (text + attachments; pure) beside its observed talkbackOpen diff --git a/docs/agents/e2e-headless.md b/docs/agents/e2e-headless.md index e647f8e4..f977f636 100644 --- a/docs/agents/e2e-headless.md +++ b/docs/agents/e2e-headless.md @@ -118,6 +118,9 @@ app.multiplexterm.multiplex`): - `MULTIPLEX_FORCE_SHELL=1|0` — force the single-window shell on/off. Default: iPhone always shell, iPad only when `UIWindowScene.isFullScreen`, visionOS never. Logged under category `shell`. +- `MULTIPLEX_SIDE_PANEL=0` — DEBUG-only legacy road: terminal-confirmed ▤/⌗ + viewers dock as tabs instead of side panels. Pass + `SIMCTL_CHILD_MULTIPLEX_SIDE_PANEL=0` to preserve older recipes. **iOS 27 scene/screenshot notes:** a first-ever install can connect an empty scene (uninstall from that sim, then install + launch once more); a freshly @@ -136,7 +139,18 @@ Synthetic events (System Events) never reach a *simulator* but DO reach the Mac app — how the Mac keyboard paths were verified headlessly. Drive a live session from the Mac side: `tmux send-keys -t main:2 'echo hi' -Enter`. +Enter`. ⚠ The attached client shows the session's CURRENT window, and `demo` +leaves `main` on `1:server` (a busy fake API log whose `/v1/sessions` lines are +themselves path-shaped — `debug.link` will pick them up); for a proof that +needs its own text on screen, `tmux new-window -t main -n proof` first and +send-keys to `main:proof`. + +⚠ A proof driver that only `simctl terminate`s and relaunches runs whatever +the simulator already has: `xcrun simctl install ` +after every rebuild (keeps the container; `uninstall` first if SpringBoard +shows a placeholder icon and refuses the launch — "denied by service delegate +(SBMainWorkspace)"). A stale binary once passed as "geometry unchanged" +(2026-08-21). **mosh path**: seed with `state/seed-mosh.json` (same host/UUID, `useMosh: true`; `seed-mosh-v6.json` for IPv6). SSH is used only to launch @@ -212,7 +226,16 @@ app.multiplexterm.multiplex.`: text both resolvers decline must present nothing). `debug.linkopen` runs OPEN; `debug.viewportopen` runs the sheet's ⌗ VIEWPORT chip; `debug.linkregions` logs the visionOS gaze-region inventory (category - `links`, debug level). + `links`, debug level). `debug.link` + `debug.pathview` or + `debug.viewportopen` now lands in a panel in a classic visionOS window or an + iPad window whose pane is regular-width and ≥660 pt; use the env override + above to prove the old tab road. +- `debug.sidepanelsplit` / `debug.sidepanelclose` — press the active panel's + ↗ TAB / ✕ in the focused terminal window. Like every terminal hook they + need the terminal to own `TerminalFocusArbiter`; a fresh seeded launch + occasionally leaves it unclaimed, so fire `debug.summon` once first. `debug.sidepanelwidth` persists + the next width (visionOS: the next of 400/520/680; iPad: 360 ↔ 560), so a + relaunch screenshot pair proves both resize and memory. - `debug.msgjump` / `debug.msgjumpback` — jump the focused Claude terminal to its oldest prompt / BACK TO LIVE; prove both with host-side capture-pane. diff --git a/docs/agents/i18n.md b/docs/agents/i18n.md index 00eff3ec..71e5d776 100644 --- a/docs/agents/i18n.md +++ b/docs/agents/i18n.md @@ -27,7 +27,12 @@ re-litigate without new facts. - **The build fills the catalog, never a hand.** `xcodebuild` emits `.stringsdata` (`SWIFT_EMIT_LOC_STRINGS`) but only the Xcode IDE folds them back; `./Tools/build.sh strings` runs the same `xcstringstool sync` after - `build vos`. Keys that vanish from source are marked `stale` (kept for + `build vos`. ⚠ A visionOS build never emits the iPad-only sites + (`#if !os(visionOS)`: dictation, keyboard lock, camera, QR bind), so an + Xcode IDE build for iPad can surface dozens of "new" keys at once and fail + `StringCatalogTests` (2026-08-21: 40 keys + the Info.plist bundle names, + which are proper nouns → `shouldTranslate: false`). After adding iPad-only + strings, sync from an iPad build's `.stringsdata` too. Keys that vanish from source are marked `stale` (kept for history until you delete them), new keys arrive empty — translate them. - `StringCatalogTests` fails the suite when any non-stale key lacks a zh-Hant or ja value (`needs_review` counts) unless it is `shouldTranslate: false`, diff --git a/docs/agents/input-and-windows.md b/docs/agents/input-and-windows.md index 0f617630..1a6ba597 100644 --- a/docs/agents/input-and-windows.md +++ b/docs/agents/input-and-windows.md @@ -172,6 +172,28 @@ routing, tab moves, keyboard avoidance, or secret fields. above is about restacking keys under the UMD console line. Shell/iPad keep the in-window rails unchanged; re-verify placement visually when touching any of this. +- **The side panel never participates in terminal layout or focus.** iPad + mounts the card over the pane container's inset rectangle, reuses the + pane's `keyboardObstruction`, and the address field borrows the host + terminal through `TerminalFocusArbiter.lend`. Its action row is a header on + iPad and a bottom bar on visionOS (`SidePanelRowEdge`) and surfaces chips in + measured priority order — `⋯` only for what did not fit. Classic visionOS + hangs it from a third trailing ornament whose content is ONE static mount: + a transparent strip twice the window's width, centred on the glass edge + (`SidePanelWidth.visionStripWidth(windowWidth:)`); the card lives inside it + as (width, `overhang` = right edge past the GLASS) with a handle on each + edge above the row, and the strip's empty part passes hits through + (`SidePanelStripView`). Decisions that cost device rounds — don't re-open + without new facts: geometry persists GLASS-relative, never strip-relative + (a strip-relative inset outlived a strip-width change and pinned the card); + a drag FREEZES the card into one blurred bitmap until RELEASE (no mid-drag + settle thaw — gaze-and-pinch events arrive with gaps), moves only frames, + and under GLASS the system platter steps out for the drag; nothing about + the ornament changes per tick (a SwiftUI-driven drag and a per-tick UIKit + relayout were both unusable on device) and its content carries no + animation/transition. ⚠ visionOS 27 sim only: after ↗ TAB on a ▤ panel the + glass keeps the old frame 3–6 s (CPU idle; ⌗, iPad and the shell present + in ≤ 1 s) — judge on device, don't chase it. - **A terminal window's title bar is app-owned, and its scene asks for `.minimal` window controls** (`TerminalClassicRailInsets`; `MultiplexSceneDelegate.preferredWindowingControlStyle(for:)`). Two diff --git a/docs/agents/links-and-viewers.md b/docs/agents/links-and-viewers.md index ae1e6e6c..1ebfac5f 100644 --- a/docs/agents/links-and-viewers.md +++ b/docs/agents/links-and-viewers.md @@ -240,6 +240,21 @@ link/path resolution, the ⌗ viewport, or the ▤ file viewer. ⚠ `mountBlocksIfNeeded` must `setNeedsLayout` the scroll view before reading `contentSize`, or a teardown's stale tall height reads as a full viewport and the screen stays BLANK until the reader scrolls. +- **The side panel is the first home for a terminal-summoned ▤/⌗ on iPad and + visionOS** (2026-08-21; `SidePanelViewController`, `SidePanelPolicy`, + `SidePanelWidth`): an overlay over the pane on regular-width iPad (≥ 660 pt) + and a trailing ornament on visionOS, so SwiftTerm never receives a resize. + One panel per host terminal tab — it follows the tab through merge/split, + hides without stopping behind another tab, and a second summon replaces and + shuts down the first. Path/link confirmations and external file actions on + an active terminal use it; + TAB ▸ File Viewer, tree-row "open in new tab", + summons anchored to an auxiliary tab, and README links from ▤ stay on the + tab road. ↗ TAB re-registers the live controller as an auxiliary tab + (`routeMode`); closing the panel or host tab shuts it down; panels are never + restored; a Stage Manager shrink below the floor makes the same panel→tab + move. Width is device-local and dragged live (iPad 320 … pane − 320; + visionOS from a handle on either edge, stored glass-relative). DEBUG + `MULTIPLEX_SIDE_PANEL=0` forces the tab road. - **PDFs and sound files are screens, not BINARY** (2026-08-15; `FileRenderKind.pdf` / `.audio`, `FileViewerPDFContentView`, `FileViewerAudioContentView`, `FileViewerAudioClip`). Both are read WHOLE diff --git a/docs/store-metadata.md b/docs/store-metadata.md index 071d3516..2d892996 100644 --- a/docs/store-metadata.md +++ b/docs/store-metadata.md @@ -109,7 +109,7 @@ Free surfaces added 2026-07-18 (unreleased; ships with the next binary): | Widget extension | `MultiplexWidgets` (`app.multiplexterm.multiplex.widgets`), embedded in the app; iPadOS 17.0+, visionOS 26.0+ (WidgetKit does not exist on earlier visionOS — the app itself stays 1.0) | | Widgets | "Host Monitor" (small/medium, configurable: host, small-tap action, agent, optional model picked from the host's configured launch models, optional target session with an Open In placement, optional working directory from the host's configured list, ask-for-prompt) and "Fleet Wall" (medium/large, host order follows the deck) | | Widget data | Last-known sessions/miniatures plus configured agent model names and working directories from an App Group snapshot (`group.app.multiplexterm.multiplex`, `widget-state.json`, secret-free). Widgets never open connections and show no liveness claims — a relative SEEN stamp only | -| App Shortcuts | "Open Shell" (attach the selected backend's most recent session or create), "Open File" (remote file path plus optional positive line number, or a tool-call-style `path:10-15` line range; opens read-only in a File Viewer tab, with relative paths based at the host's first configured working directory or home), and "Open Agent" (Claude Code/Codex/Pi, host-configured working-directory, setup-script, and launch-model pickers — models passed as `--model` — optional first prompt; works on tmux and herdr hosts, with Session + Open In pickers to launch inside an existing session: a new tmux window, or a herdr tab in the focused workspace / a new workspace) — run in-app; failures surface as an in-app alert | +| App Shortcuts | "Open Shell" (attach the selected backend's most recent session or create), "Open File" (remote file path plus optional positive line number, or a tool-call-style `path:10-15` line range; opens read-only in the active terminal's side panel on iPad/Vision Pro when one is available, otherwise in a File Viewer tab, with relative paths based at the host's first configured working directory or home), and "Open Agent" (Claude Code/Codex/Pi, host-configured working-directory, setup-script, and launch-model pickers — models passed as `--model` — optional first prompt; works on tmux and herdr hosts, with Session + Open In pickers to launch inside an existing session: a new tmux window, or a herdr tab in the focused workspace / a new workspace) — run in-app; failures surface as an in-app alert | | URL scheme | `multiplex://open?host=&action=shell\|agent[&session=…][&agent=…][&prompt=…][&ask=1][&dir=…][&script=][&model=…][&in=tab\|workspace\|window]` or `multiplex://open?host=&action=file&path=…[&line=…]` — widget taps and user automation; omitting `script` uses the remembered New Session choice, omitting `model` uses the agent's default; on `action=agent`, `session` targets an existing session and `in` places the launch inside it (tmux: a new window; herdr: tab in the focused workspace / new workspace) | | Privacy impact | None: the App Group snapshot stays on-device, contains no credentials, and adds no new data collection; setup-script bodies remain in the app's host record and never become Shortcut parameter values | @@ -238,9 +238,11 @@ Current product split: where the remote is not tracking the mouse; the confirmation shows the resolved target and its host, and unsupported schemes are shown for copying rather than followed), an - inline viewport browser for confirmed web links (⌗): the page docks as a - tab beside the session that printed it, splits into its own window and - merges back like any tab with the live page riding along, the sheet's + inline viewport browser for confirmed web links (⌗): on iPad and Vision Pro + the page first floats beside the terminal without resizing it, and ↗ TAB + re-keys the live page into a tab beside that session (compact iPhone and + narrow iPad stages use the tab directly); the resulting tab splits into its + own window and merges back with the live page riding along, the sheet's REACH row says which network the address lives on and rewrites a remote `localhost` to the host's own dialled address in the open, the rail's address is tap-to-edit (typed addresses ride the same web-only gate and diff --git a/fastlane/testflight-whats-new.txt b/fastlane/testflight-whats-new.txt index f8539d8f..38eb7523 100644 --- a/fastlane/testflight-whats-new.txt +++ b/fastlane/testflight-whats-new.txt @@ -1,6 +1,7 @@ NEW SINCE 1.4.0 • COPY COMMAND FOR HANDOFF — long-press a session tile on the deck to copy the attach line for a terminal on that machine itself (tmux and herdr alike). • TRADITIONAL CHINESE AND JAPANESE — pick per app in Settings → Multiplex → Language (LANGUAGE in Multiplex Settings takes you there). +• SIDE PANEL — on iPad and Vision Pro a confirmed path or web link now opens beside the terminal instead of as a tab; resize it, open another (it replaces the first), or press TAB to move it into a tab. The terminal never changes size. PLEASE TRY 1. Long-press a session tile, pick COPY COMMAND FOR HANDOFF, and paste it into a terminal on the host machine — it should attach the very session the tile shows.