From 4017fdc71910352d74f6a80cd4f8f55c79a287d9 Mon Sep 17 00:00:00 2001 From: Lakr Date: Wed, 21 Oct 2026 18:17:09 +0800 Subject: [PATCH 1/2] Paste copied text as text, and switch tabs under a drag Text copied in the app on the Mac pasted percent-encoded: Catalyst's UIPasteboard.string registers public.url beside any text that starts like a scheme, and the library pasted URLs first, so "Worker: 04" and a line of Chinese reached Codex as Worker:%2004%0A%E6... libghostty-spm 2.2.2026102101 pastes a URL that percent-decodes to the string as the string; the app's own drop path gets the same rule. A drag from elsewhere resting on a tab - a sidebar row or a strip chip - brings that tab forward after 0.4 s, so the drop can land on its terminal. The tab takes nothing; the drop is still made on the terminal. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 24 ++++- iGhostVT.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Session/TerminalDropDelegate.swift | 17 ++++ iGhostVT/Interface/Bars/TabStripBar.swift | 1 + iGhostVT/Interface/Support/TabDragHover.swift | 90 +++++++++++++++++++ iGhostVT/Interface/Support/TabReorder.swift | 23 ++++- 7 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 iGhostVT/Interface/Support/TabDragHover.swift diff --git a/AGENTS.md b/AGENTS.md index d1d0499..390a7e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,8 +57,14 @@ which launchd never sized — so a session's buffers cannot jetsam the daemon. for exactly this reason. - Depends on the **released** [libghostty-spm](https://github.com/Lakr233/libghostty-spm) package - (`upToNextMajor` from 2.2.2026101901, Ghostty 1e9e14af on Zig 0.16). - 2.2.2026101901 keeps Shift, Option and Control on Return: on Catalyst + (`upToNextMajor` from 2.2.2026102101, Ghostty 1e9e14af on Zig 0.16). + 2.2.2026102101 pastes text as the text it is when UIKit also offers it + as a link: on Catalyst, `UIPasteboard.string =` — the terminal's own + copy, an OSC 52 write — registers `public.url` beside any text that + starts like a scheme, percent-encoded, and the URL-first rule pasted + `Worker: 04` and a line of Chinese as `Worker:%2004%0A%E6…`. A URL that + percent-decodes to the string is now dropped; the same Swift-only binary + `upstream.1e9e14af5df1-3`. 2.2.2026101901 keeps Shift, Option and Control on Return: on Catalyst modified Return is claimed as a key command, and hardware control keys carry no C0/DEL text that would make the kitty encoder drop consumed modifiers. Shift+Enter now inserts a newline in Claude Code instead of @@ -1573,7 +1579,19 @@ Gotchas that bit us: `LockableTerminalView` takes `paste(_:)`, ⌘V and the touch menu's Paste and hands the pasteboard's providers to `TerminalDropDelegate.deliver`; text stays ghostty's paste. The accessory bar's Paste key calls the - library's internal paste directly and still stages locally. + library's internal paste directly and still stages locally. UIKit + registers `public.url` beside any text that starts like a scheme + (`Worker: 04`, `C:\path`), percent-encoded since iOS 17, so a link + whose percent-decoded spelling is the item's own text pastes the text + (`TerminalDropDelegate.isReading`; the library's paste has the same rule + since 2.2.2026102101) — `Worker:%2004%0A…` once reached Codex that way. + A drag from elsewhere resting 0.4 s on a tab — a sidebar row or a strip + chip — brings that tab forward (`TabDragHover`), and that is all: the tab + takes nothing, and the drop is still made on the terminal. The sidebar + row's reorder delegate answers both kinds of drag, since a second + `onDrop` on the row would compete with it. The Mac's title-bar band, + which never hands a drop to the content, still reports the hover — a + chip there switches like a row. - **The Mac window's chrome is AppKit's, reached through the ObjC runtime.** `CatalystWindowChrome.install()` (called from `main.swift`, before any scene) hooks `UINSApplicationDelegate didCreateUIScene:`. The sidebar has diff --git a/iGhostVT.xcodeproj/project.pbxproj b/iGhostVT.xcodeproj/project.pbxproj index e81ef30..7869651 100644 --- a/iGhostVT.xcodeproj/project.pbxproj +++ b/iGhostVT.xcodeproj/project.pbxproj @@ -1314,7 +1314,7 @@ repositoryURL = "https://github.com/Lakr233/libghostty-spm"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.2.2026101901; + minimumVersion = 2.2.2026102101; }; }; AA0000000000000000000A02 /* XCRemoteSwiftPackageReference "Dog" */ = { diff --git a/iGhostVT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iGhostVT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a0b7506..ef9d575 100644 --- a/iGhostVT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iGhostVT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Lakr233/libghostty-spm", "state" : { - "revision" : "dfe0cb03415cb2371d71ed46cd4db0614bef84eb", - "version" : "2.2.2026101901" + "revision" : "5b9cb63d83e687b4a66d6c3a3c2a840341c576d9", + "version" : "2.2.2026102101" } }, { diff --git a/iGhostVT/Backend/Session/TerminalDropDelegate.swift b/iGhostVT/Backend/Session/TerminalDropDelegate.swift index 9e80d6f..1baeb1d 100644 --- a/iGhostVT/Backend/Session/TerminalDropDelegate.swift +++ b/iGhostVT/Backend/Session/TerminalDropDelegate.swift @@ -245,6 +245,12 @@ final class TerminalDropDelegate: NSObject, UIDropInteractionDelegate { } } if isLink, let url = await loadURL() { + // Dragged text registers a link as well — any `word:` + // parses as one, percent-encoded since iOS 17 — and pasted + // that way `Worker: 04` arrived as `Worker:%2004`. + if isText, let text = await loadText(), TerminalDropDelegate.isReading(of: text, url) { + return .text(text) + } return .text(url.absoluteString) } if isText || fileType == nil, let text = await loadText() { @@ -506,6 +512,17 @@ final class TerminalDropDelegate: NSObject, UIDropInteractionDelegate { } } + /// Whether `url` spells nothing but `text`: equal once each is + /// percent-decoded, which undoes whichever encoding the parse chose. The + /// library's paste applies the same rule (`TerminalPasteboardContent`, + /// internal there). + nonisolated static func isReading(of text: String, _ url: URL) -> Bool { + func decoded(_ string: String) -> String { + (string.removingPercentEncoding ?? string).trimmingCharacters(in: .whitespacesAndNewlines) + } + return decoded(url.absoluteString) == decoded(text) + } + /// A C0 or C1 control, or DEL: what the shell escape cannot make inert. nonisolated static func containsControlCharacter(_ path: String) -> Bool { path.unicodeScalars.contains { $0.value < 0x20 || (0x7F ... 0x9F).contains($0.value) } diff --git a/iGhostVT/Interface/Bars/TabStripBar.swift b/iGhostVT/Interface/Bars/TabStripBar.swift index f8c37a9..753ebfd 100644 --- a/iGhostVT/Interface/Bars/TabStripBar.swift +++ b/iGhostVT/Interface/Bars/TabStripBar.swift @@ -159,6 +159,7 @@ struct TabStripBar: View { onClose: { tabManager.requestClose(tab, from: .closeButton) }, ) .frame(width: width) + .switchesOnDragHover(tab, in: tabManager) #if targetEnvironment(macCatalyst) // A touch screen's long press is the reorder, // and nothing else: a menu that opened when the diff --git a/iGhostVT/Interface/Support/TabDragHover.swift b/iGhostVT/Interface/Support/TabDragHover.swift new file mode 100644 index 0000000..0526ddc --- /dev/null +++ b/iGhostVT/Interface/Support/TabDragHover.swift @@ -0,0 +1,90 @@ +// +// TabDragHover.swift +// iGhostVT +// + +import SwiftUI +import UniformTypeIdentifiers + +/// A drag from elsewhere — a file out of the Finder, a snippet of text, an +/// image — held over a tab brings that tab to the front, the way a +/// browser's tab strip does, so the drop can land on its terminal. That is +/// all a tab does with it: it accepts nothing, a release over it ends the +/// drag with nothing pasted, and the drop itself still goes to the terminal +/// (`TerminalDropDelegate`) and takes its usual path. The tab waits a beat +/// (`dwell`) before it switches, so a drag crossing the sidebar on its way +/// to the terminal does not flip every tab it passes. A tab's own drag is +/// the reorder's (`TabReorder`) and never switches anything. +@MainActor +enum TabDragHover { + /// How long the pointer rests on a tab before the tab comes forward. + static let dwellNanoseconds: UInt64 = 400_000_000 + + /// The type a hover target registers for: everything, since whatever + /// the terminal could take is worth switching for. A tab's own drag + /// conforms to nothing and so is not among it (`TabReorder.itemType`). + static let types: [UTType] = [.item] + + /// The tab the pointer rests on and the switch waiting for it. One drag + /// at a time is all the system allows, so one record suffices. + private static var pending: (tabID: UUID, switch: Task)? + + /// Whether this hover answers `info`: anything but a tab's own drag. + static func answers(_ info: DropInfo) -> Bool { + !info.hasItemsConforming(to: [TabReorder.itemType]) + } + + static func enter(_ tab: TerminalTab, in tabManager: TabManager) { + guard pending?.tabID != tab.id else { return } + pending?.switch.cancel() + let switchTab = Task { @MainActor [weak tab, weak tabManager] in + try? await Task.sleep(nanoseconds: dwellNanoseconds) + guard !Task.isCancelled, let tab, let tabManager else { return } + pending = nil + tabManager.activate(tab) + } + pending = (tab.id, switchTab) + } + + static func leave(_ tab: TerminalTab) { + guard let pending, pending.tabID == tab.id else { return } + pending.switch.cancel() + self.pending = nil + } +} + +/// A tab presentation that only switches (the top bar's chips); a sidebar +/// row reorders as well, and its slot delegate answers both +/// (`TabReorderSlotDelegate`). +struct TabDragHoverDelegate: DropDelegate { + let tab: TerminalTab + let tabManager: TabManager + + func validateDrop(info: DropInfo) -> Bool { + TabDragHover.answers(info) + } + + func dropEntered(info _: DropInfo) { + TabDragHover.enter(tab, in: tabManager) + } + + func dropUpdated(info _: DropInfo) -> DropProposal? { + DropProposal(operation: .cancel) + } + + func dropExited(info _: DropInfo) { + TabDragHover.leave(tab) + } + + func performDrop(info _: DropInfo) -> Bool { + TabDragHover.leave(tab) + return false + } +} + +extension View { + /// Brings `tab` forward when a drag from elsewhere rests on this view. + func switchesOnDragHover(_ tab: TerminalTab, in tabManager: TabManager) -> some View { + onDrop(of: TabDragHover.types, delegate: TabDragHoverDelegate(tab: tab, tabManager: tabManager)) + } +} diff --git a/iGhostVT/Interface/Support/TabReorder.swift b/iGhostVT/Interface/Support/TabReorder.swift index e5569f6..2557dc8 100644 --- a/iGhostVT/Interface/Support/TabReorder.swift +++ b/iGhostVT/Interface/Support/TabReorder.swift @@ -194,7 +194,7 @@ extension View { TabReorder.itemProvider(for: tab, in: tabManager) } .onDrop( - of: [TabReorder.itemType], + of: [TabReorder.itemType] + TabDragHover.types, delegate: TabReorderSlotDelegate(tab: tab, tabManager: tabManager, pacing: pacing), ) } else { @@ -244,20 +244,26 @@ extension View { } } +/// A tab's own drag reorders; any other drag resting on the slot brings +/// its tab forward (`TabDragHover`) and is not taken here. One delegate +/// for both, since a second `onDrop` on the same row would compete with +/// this one for the drag. private struct TabReorderSlotDelegate: DropDelegate { let tab: TerminalTab let tabManager: TabManager let pacing: TabReorderPacing func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [TabReorder.itemType]) + info.hasItemsConforming(to: [TabReorder.itemType]) || TabDragHover.answers(info) } func dropEntered(info: DropInfo) { + guard isReorder(info) else { return TabDragHover.enter(tab, in: tabManager) } moveDraggedTabHere(info, force: true) } func dropUpdated(info: DropInfo) -> DropProposal? { + guard isReorder(info) else { return DropProposal(operation: .cancel) } // The catch-up path: when a move slides this slot under a pointer // that never crossed its edge, no `dropEntered` fires — the update // stream is what still arrives, so the reorder keeps following the @@ -266,8 +272,17 @@ private struct TabReorderSlotDelegate: DropDelegate { return DropProposal(operation: .move) } - func performDrop(info _: DropInfo) -> Bool { - true + func dropExited(info _: DropInfo) { + TabDragHover.leave(tab) + } + + func performDrop(info: DropInfo) -> Bool { + TabDragHover.leave(tab) + return isReorder(info) + } + + private func isReorder(_ info: DropInfo) -> Bool { + info.hasItemsConforming(to: [TabReorder.itemType]) } /// Puts the dragged tab in this slot. An entered slot moves at once; From 6964994f9d0e92f863557a44ca3abd0be34a66ae Mon Sep 17 00:00:00 2001 From: Lakr Date: Wed, 21 Oct 2026 18:31:29 +0800 Subject: [PATCH 2/2] Write the 1.6.18 note Co-Authored-By: Claude Opus 5.5 --- Documents/Releases/1.6.18.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 Documents/Releases/1.6.18.md diff --git a/Documents/Releases/1.6.18.md b/Documents/Releases/1.6.18.md new file mode 100644 index 0000000..0ea8167 --- /dev/null +++ b/Documents/Releases/1.6.18.md @@ -0,0 +1,6 @@ +iGhostVT 1.6.18 stops text copied on the Mac from pasting as a percent-encoded string, and switches to a tab when a drag rests on it. + +- On the Mac, text copied in iGhostVT could paste as a percent-encoded string such as `Worker:%2004%0A%E6%9E%84…` instead of the text. This happened when the text began with a word and a colon, such as `Worker: 04`, and also happened when such text was dragged onto the terminal. The text now pastes exactly as it was copied. Links and file paths paste as before. +- Drag a file or text from another app and hold it over a tab in the sidebar or the tab bar. After a moment, that tab comes to the front. Drop on its terminal as usual. Releasing over the tab drops nothing. + +Choose `iphoneos-arm64e` for roothide, `iphoneos-arm64` for a rootless bootstrap, `iGhostVT-1.6.18-macos-notarized.zip` for a Mac — it is signed and notarized, so it opens without clearing quarantine, and updates itself — or `GhostRemote-1.6.18.ipa` for an iPhone or iPad without custom firmware, which your sideloading app signs as it installs it. To run the relay, use the attached `compose.yml`; the image is `ghcr.io/owngoal-dev/ighostvt-relay`, and the digest is in `relay-image.txt`. SHA-256 sums are in `SHA256SUMS`, and the Mac app's in `SHA256SUMS.macos`.