diff --git a/.gitmodules b/.gitmodules index 6ef7b440..b0692538 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,7 @@ [submodule "libssh2-spm"] path = libssh2-spm url = https://github.com/AkinoKaede/libssh2-spm.git +[submodule "libghostty-spm"] + path = libghostty-spm + url = https://github.com/AkinoKaede/libghostty-spm.git + branch = termind diff --git a/AGENTS.md b/AGENTS.md index 023985ae..433831e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,19 @@ The agent runtime is `AgentKit`, a separate open-source package developed in the Xcode builds the pinned tag, not the sibling working tree, so an uncommitted change in `../AgentKit` is invisible to the app. A change that spans both sides takes three steps: make and test the package change, tag and push a release from `../AgentKit`, then bump `minimumVersion` in `project.pbxproj` and re-resolve so `Package.resolved` records the new revision. Both files belong in the same commit as the app-side change that needs them. +`libghostty-spm/` is a maintenance submodule of the public +`AkinoKaede/libghostty-spm` fork, tracking `termind`; upstream is +`Lakr233/libghostty-spm`. Xcode consumes the fork as a remote SPM dependency +pinned to an exact commit, not the submodule working tree. Push package changes +to `termind`, then update the submodule gitlink, `project.pbxproj` revision and +`Package.resolved` together. Run `git submodule update --init libghostty-spm` +to obtain the maintenance checkout. Changes to `Patches/ghostty/` require a new +fork XCFramework release and a matching URL/checksum in `Package.swift` before +advancing the app's pin. The package's native iOS touch menus expose +separate subclass hooks, `touchMenuItems(for:)` and +`touchSelectionMenuItems(for:)`, before and after selection. Override them on +the platform view, with grouping and localization owned by Termind. + ## Architecture Records Treat `openwiki/` as durable just-in-time engineering context. Start with `openwiki/quickstart.md` before changing dependencies, navigation ownership, state lifetimes, persistence, agent execution, permissions, secret handling, or release operations, then follow its topic-specific page to current source and tests. Source code and tests remain authoritative. Refresh invalidated OpenWiki content through the OpenWiki lifecycle and keep `ArchitectureBoundaryTests` aligned; do not maintain a parallel refactoring log. diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 37abda80..56e5de1d 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -24823,6 +24823,23 @@ } } }, + "Select" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選取" + } + } + } + }, "Select %@ for batch actions" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindPresentation/Strings/Common.swift b/Frameworks/TermindPresentation/Strings/Common.swift index 2b1005de..a090fd9f 100644 --- a/Frameworks/TermindPresentation/Strings/Common.swift +++ b/Frameworks/TermindPresentation/Strings/Common.swift @@ -230,6 +230,10 @@ extension AppStrings { "Search" } + public static var select: LocalizedStringResource { + "Select" + } + public static var selectAll: LocalizedStringResource { "Select All" } diff --git a/TFI/Platform/MobileTerminalSurface.swift b/TFI/Platform/MobileTerminalSurface.swift index 55ae677a..a5ef0602 100644 --- a/TFI/Platform/MobileTerminalSurface.swift +++ b/TFI/Platform/MobileTerminalSurface.swift @@ -7,7 +7,17 @@ import UIKit /// A workspace owns its native surface even while SwiftUI navigates away from it. @MainActor final class MobileTerminalSurface { - let view = MobileTerminalView(frame: .zero) + let view: MobileTerminalView = { + let view = MobileTerminalView(frame: .zero) + view.usesInlineTextSelection = true + view.touchSelectionTitles = TerminalTouchSelectionTitles( + paste: String(localized: AppStrings.Common.paste), + select: String(localized: AppStrings.Common.select), + selectAll: String(localized: AppStrings.Common.selectAll), + copy: String(localized: AppStrings.Common.copy) + ) + return view + }() func bindFind(_ find: TerminalFindController) { view.findController = find diff --git a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift index 6d5318ba..09188a5e 100644 --- a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift +++ b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift @@ -3,6 +3,7 @@ import GhosttyTerminal import SwiftUI import TermindApplication + import TermindPresentation /// Mounts one long-lived Ghostty view into whichever pane currently owns it. /// @@ -194,11 +195,7 @@ if pastes { // An empty paste must not arm a deferred broadcast ticket that // could consume the next unrelated mouse or protocol report. - let pasteboard = NSPasteboard.general - guard - pasteboard.string(forType: .string)?.isEmpty == false - || pasteboard.canReadObject(forClasses: [NSURL.self], options: nil) - else { return } + guard canPasteFromClipboard else { return } captureInteraction { _ = performBindingAction("paste_from_clipboard") } } else { super.rightMouseUp(with: event) @@ -206,6 +203,7 @@ } override func menu(for event: NSEvent) -> NSMenu? { + guard !isMouseCaptured else { return nil } if event.type == .rightMouseDown || event.type == .rightMouseUp, rightClickPastes ?? shouldPasteOnRightClick(event) { @@ -214,6 +212,46 @@ return super.menu(for: event) } + override func selectionMenuPoint(at point: CGPoint) -> CGPoint? { + // The superclass uses this hook to decide whether a right-click + // presents a menu. Offer the app's default menu without a selection too. + isMouseCaptured ? nil : point + } + + override func selectionContextMenu() -> NSMenu { + let menu: NSMenu + if super.selectionMenuPoint(at: .zero) != nil { + menu = super.selectionContextMenu() + menu.items.first?.title = String(localized: AppStrings.Common.copy) + } else { + menu = NSMenu() + } + menu.autoenablesItems = false + let pasteItem = NSMenuItem( + title: String(localized: AppStrings.Common.paste), + action: #selector(pasteFromContextMenu(_:)), keyEquivalent: "") + pasteItem.target = self + pasteItem.isEnabled = canPasteFromClipboard + menu.addItem(pasteItem) + let selectAllItem = NSMenuItem( + title: String(localized: AppStrings.Common.selectAll), + action: #selector(selectAll(_:)), keyEquivalent: "") + selectAllItem.target = self + menu.addItem(selectAllItem) + return menu + } + + private var canPasteFromClipboard: Bool { + let pasteboard = NSPasteboard.general + return pasteboard.string(forType: .string)?.isEmpty == false + || pasteboard.canReadObject(forClasses: [NSURL.self], options: nil) + } + + @objc private func pasteFromContextMenu(_: Any?) { + guard canPasteFromClipboard else { return } + captureInteraction { _ = performBindingAction("paste_from_clipboard") } + } + private func shouldPasteOnRightClick(_ event: NSEvent) -> Bool { pasteOnRightClick && !isMouseCaptured && !event.modifierFlags.contains(.option) } diff --git a/TFMTests/TerminalContextMenuTests.swift b/TFMTests/TerminalContextMenuTests.swift new file mode 100644 index 00000000..d253c2ae --- /dev/null +++ b/TFMTests/TerminalContextMenuTests.swift @@ -0,0 +1,48 @@ +import AppKit +import TermindPresentation +import Testing + +@testable import Termind + +@Suite @MainActor +struct TerminalContextMenuTests { + @Test func defaultMenuValidatesPasteAndOptionBypassesAutomaticPaste() throws { + let pasteboard = NSPasteboard.general + let saved = (pasteboard.pasteboardItems ?? []).map { item in + Dictionary( + uniqueKeysWithValues: item.types.compactMap { type in + item.data(forType: type).map { (type, $0) } + }) + } + defer { + pasteboard.clearContents() + pasteboard.writeObjects( + saved.map { values in + let item = NSPasteboardItem() + for (type, data) in values { item.setData(data, forType: type) } + return item + }) + } + let view = TermindTerminalView(frame: .zero) + let event = try #require( + NSEvent.mouseEvent( + with: .rightMouseDown, location: .zero, modifierFlags: [], timestamp: 1, + windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)) + pasteboard.clearContents() + let emptyMenu = try #require(view.menu(for: event)) + let pasteTitle = String(localized: AppStrings.Common.paste) + #expect(emptyMenu.item(withTitle: pasteTitle)?.isEnabled == false) + #expect(emptyMenu.items.contains { $0.action == #selector(view.selectAll(_:)) && $0.isEnabled }) + #expect(!emptyMenu.items.contains { $0.action == #selector(view.copy(_:)) }) + + pasteboard.setString("echo hello", forType: .string) + #expect(view.menu(for: event)?.item(withTitle: pasteTitle)?.isEnabled == true) + view.pasteOnRightClick = true + #expect(view.menu(for: event) == nil) + let optionEvent = try #require( + NSEvent.mouseEvent( + with: .rightMouseDown, location: .zero, modifierFlags: .option, timestamp: 1, + windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)) + #expect(view.menu(for: optionEvent)?.item(withTitle: pasteTitle)?.isEnabled == true) + } +} diff --git a/TFMTests/TerminalMouseInteractionTests.swift b/TFMTests/TerminalMouseInteractionTests.swift index bca42194..45a59dc6 100644 --- a/TFMTests/TerminalMouseInteractionTests.swift +++ b/TFMTests/TerminalMouseInteractionTests.swift @@ -208,7 +208,8 @@ struct TerminalMouseInteractionTests { #expect(fixture.firstWrites.isEmpty) fixture.setClipboard("do not paste") fixture.view.rightMouseDown(with: try fixture.event(.rightMouseDown, modifiers: .option)) - fixture.view.rightMouseUp(with: try fixture.event(.rightMouseUp)) + // Inspect the Option menu without entering AppKit's modal tracking loop. + #expect(fixture.view.menu(for: try fixture.event(.rightMouseDown, modifiers: .option)) != nil) try await fixture.flushWrites() #expect(fixture.firstWrites.isEmpty) fixture.feed("selection") @@ -260,7 +261,8 @@ struct TerminalMouseInteractionTests { fixture.setClipboard("paste once") fixture.view.rightMouseDown(with: try fixture.event(.rightMouseDown)) fixture.view.pasteOnRightClick = true - fixture.view.rightMouseUp(with: try fixture.event(.rightMouseUp)) + // The click already chose the menu route; avoid presenting it in this test. + #expect(fixture.view.menu(for: try fixture.event(.rightMouseDown)) != nil) try await fixture.flushWrites() #expect(fixture.firstWrites.isEmpty) try fixture.rightClick() diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 7cd10875..5fc30d29 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2940,10 +2940,10 @@ }; TRMD00000000000000100022 /* XCRemoteSwiftPackageReference "libghostty-spm" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Lakr233/libghostty-spm"; + repositoryURL = "https://github.com/AkinoKaede/libghostty-spm.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.6.20260922; + kind = revision; + revision = "21d9f81178fbb9a7d115a3a19cd9956fd5259f70"; }; }; TRMD00000000000000100023 /* XCRemoteSwiftPackageReference "openssl-spm" */ = { diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c45b5b30..f2e35ae9 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b4d0d2a0d4dd48bc17305a7633056c1a5aeddd04bed354f9616d8b1b31e4e888", + "originHash" : "2386f4ec13a24a310cbe47f5923c524e937b76e0eb156a380e35eca9e70ec044", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -46,6 +46,15 @@ "version" : "5.9.0" } }, + { + "identity" : "displaylink", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/DisplayLink.git", + "state" : { + "revision" : "41bea2661e04bebcb1c4f15fc0b6197850100fdd", + "version" : "3.0.1" + } + }, { "identity" : "firebase-ios-sdk", "kind" : "remoteSourceControl", @@ -157,10 +166,9 @@ { "identity" : "libghostty-spm", "kind" : "remoteSourceControl", - "location" : "https://github.com/Lakr233/libghostty-spm", + "location" : "https://github.com/AkinoKaede/libghostty-spm.git", "state" : { - "revision" : "b7f888e3baf8585ea9d590ab1a45b49475e00d1c", - "version" : "1.6.20260922" + "revision" : "21d9f81178fbb9a7d115a3a19cd9956fd5259f70" } }, { diff --git a/libghostty-spm b/libghostty-spm new file mode 160000 index 00000000..21d9f811 --- /dev/null +++ b/libghostty-spm @@ -0,0 +1 @@ +Subproject commit 21d9f81178fbb9a7d115a3a19cd9956fd5259f70