Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Documents/Releases/1.6.18.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion iGhostVT.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions iGhostVT/Backend/Session/TerminalDropDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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) }
Expand Down
1 change: 1 addition & 0 deletions iGhostVT/Interface/Bars/TabStripBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions iGhostVT/Interface/Support/TabDragHover.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>)?

/// 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))
}
}
23 changes: 19 additions & 4 deletions iGhostVT/Interface/Support/TabReorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
Loading