From 9f41e09e1253cbe086aec177d2000489d065b7cc Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 17 Oct 2026 18:39:00 +0800 Subject: [PATCH] fix: ui and live activity --- .../TermindPresentation/KeyExportForm.swift | 1 + .../MemorySettingsSection.swift | 2 +- .../ModelProviderVertexFields.swift | 2 +- .../SkillPackageEditor.swift | 2 +- .../SnippetFormSections.swift | 1 + .../TermindPresentation/Strings/Pro.swift | 2 +- .../TextEditor+Scrollers.swift | 84 +++++++ TFI/App/MobileAppModel.swift | 14 +- TFI/App/MobileSceneModel.swift | 35 +-- TFI/Features/Vault/MobileVaultView.swift | 2 +- .../MobileLiveActivityController.swift | 78 +++---- .../MobileWorkspaceSceneRouting.swift | 5 +- .../MobileLiveActivityRestorationTests.swift | 205 +++--------------- TFITests/MobileShortcutTests.swift | 29 +++ TFITests/MobileWorkspaceSessionTests.swift | 66 +++++- TFM/Features/AI/ConversationViews.swift | 67 +----- TFM/Features/Keychain/KeychainPageView.swift | 1 + TFM/Features/Settings/SkillEditorSheet.swift | 2 + ...erminalCustomConfigurationEditorPage.swift | 15 +- TFMTests/ChatComposerStateTests.swift | 5 +- ...CustomConfigurationEditorLayoutTests.swift | 18 +- TFMTests/TextEditorScrollerTests.swift | 95 ++++++++ Termind.xcodeproj/project.pbxproj | 16 +- 23 files changed, 402 insertions(+), 345 deletions(-) create mode 100644 Frameworks/TermindPresentation/TextEditor+Scrollers.swift create mode 100644 TFMTests/TextEditorScrollerTests.swift diff --git a/Frameworks/TermindPresentation/KeyExportForm.swift b/Frameworks/TermindPresentation/KeyExportForm.swift index f0513c62..ce66ee3b 100644 --- a/Frameworks/TermindPresentation/KeyExportForm.swift +++ b/Frameworks/TermindPresentation/KeyExportForm.swift @@ -95,6 +95,7 @@ public struct KeyExportForm: View { private var scriptEditor: some View { TextEditor(text: $model.options.script) + .autohidingScrollers() .font(scriptFont) .multilineTextAlignment(.leading) .frame(minHeight: 240) diff --git a/Frameworks/TermindPresentation/MemorySettingsSection.swift b/Frameworks/TermindPresentation/MemorySettingsSection.swift index 76af3375..b5314e30 100644 --- a/Frameworks/TermindPresentation/MemorySettingsSection.swift +++ b/Frameworks/TermindPresentation/MemorySettingsSection.swift @@ -70,7 +70,7 @@ public struct MemorySettingsSection: View { .sheet(isPresented: Binding(get: { editing != nil }, set: { if !$0 { editing = nil } })) { NavigationStack { Form { - TextEditor(text: $draft).frame(minHeight: 220) + TextEditor(text: $draft).autohidingScrollers().frame(minHeight: 220) if let error = memory.error { Text(error).foregroundStyle(.red) } if editing != nil { let usage = MemoryDateAnnotations.draftUsage(draft) diff --git a/Frameworks/TermindPresentation/ModelProviderVertexFields.swift b/Frameworks/TermindPresentation/ModelProviderVertexFields.swift index 762c0c9d..5e2b2bfd 100644 --- a/Frameworks/TermindPresentation/ModelProviderVertexFields.swift +++ b/Frameworks/TermindPresentation/ModelProviderVertexFields.swift @@ -98,7 +98,7 @@ public struct ModelProviderVertexFields: View { .sheet(isPresented: $pasting, onDismiss: { json = "" }) { VStack(alignment: .leading, spacing: 12) { Text("Paste Service Account JSON").font(.headline) - TextEditor(text: $json).font(.body.monospaced()).autocorrectionDisabled() + TextEditor(text: $json).autohidingScrollers().font(.body.monospaced()).autocorrectionDisabled() #if os(iOS) .textInputAutocapitalization(.never) #endif diff --git a/Frameworks/TermindPresentation/SkillPackageEditor.swift b/Frameworks/TermindPresentation/SkillPackageEditor.swift index 2d406fc9..a095f11a 100644 --- a/Frameworks/TermindPresentation/SkillPackageEditor.swift +++ b/Frameworks/TermindPresentation/SkillPackageEditor.swift @@ -73,7 +73,7 @@ public struct SkillPackageEditor: View { NavigationStack { Form { TextField("Relative Path", text: $path) - TextEditor(text: $text).font(.body.monospaced()).frame(minHeight: 240) + TextEditor(text: $text).autohidingScrollers().font(.body.monospaced()).frame(minHeight: 240) if let error { Text(error).foregroundStyle(.red) } } .navigationTitle("Supporting File") diff --git a/Frameworks/TermindPresentation/SnippetFormSections.swift b/Frameworks/TermindPresentation/SnippetFormSections.swift index fb247530..067e1abc 100644 --- a/Frameworks/TermindPresentation/SnippetFormSections.swift +++ b/Frameworks/TermindPresentation/SnippetFormSections.swift @@ -38,6 +38,7 @@ public struct SnippetScriptSection: View { public var body: some View { Section(AppStrings.Snippets.script.swiftUIKey) { TextEditor(text: $script) + .autohidingScrollers() .multilineTextAlignment(.leading) .font(.body.monospaced()) .frame(minHeight: minHeight) diff --git a/Frameworks/TermindPresentation/Strings/Pro.swift b/Frameworks/TermindPresentation/Strings/Pro.swift index 90edca11..7b257af8 100644 --- a/Frameworks/TermindPresentation/Strings/Pro.swift +++ b/Frameworks/TermindPresentation/Strings/Pro.swift @@ -21,7 +21,7 @@ extension AppStrings { public static var privacyPolicy: LocalizedStringResource { "Privacy Policy" } - + public static var termindPro: LocalizedStringResource { "Termind Pro" } diff --git a/Frameworks/TermindPresentation/TextEditor+Scrollers.swift b/Frameworks/TermindPresentation/TextEditor+Scrollers.swift new file mode 100644 index 00000000..5222f84a --- /dev/null +++ b/Frameworks/TermindPresentation/TextEditor+Scrollers.swift @@ -0,0 +1,84 @@ +import SwiftUI + +#if os(macOS) + import AppKit +#endif + +extension TextEditor { + /// Hides an unused macOS scroller without suppressing it for overflowing text. + public func autohidingScrollers() -> some View { + #if os(macOS) + background { TextEditorScrollViewConfigurator() } + #else + self + #endif + } +} + +#if os(macOS) + /// Makes AppKit hide the TextEditor's vertical scroller while its document fits. + /// + /// SwiftUI's scroll-indicator visibility does not remove the disabled scroller + /// from a macOS TextEditor when the system is configured to always show scroll + /// bars. The underlying NSScrollView already knows whether its document exceeds + /// the clip view, so leave that decision to `autohidesScrollers` instead of + /// duplicating it from an approximate text-height measurement. + private struct TextEditorScrollViewConfigurator: NSViewRepresentable { + final class ResolverView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + configureAfterLayout() + } + + override func layout() { + super.layout() + configureScrollView() + } + + func configureAfterLayout() { + Task { @MainActor [weak self] in self?.configureScrollView() } + } + + private func configureScrollView() { + guard let contentView = window?.contentView, !bounds.isEmpty, + let scrollView = textEditorScrollView(in: contentView) + else { return } + + guard !scrollView.autohidesScrollers || !scrollView.hasVerticalScroller else { return } + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.tile() + } + + private func textEditorScrollView(in root: NSView) -> NSScrollView? { + let targetFrame = convert(bounds, to: root) + let targetCenter = NSPoint(x: targetFrame.midX, y: targetFrame.midY) + return descendantScrollViews(in: root).first { scrollView in + guard let textView = scrollView.documentView as? NSTextView, textView.isEditable else { + return false + } + return scrollView.convert(scrollView.bounds, to: root).contains(targetCenter) + } + } + + private func descendantScrollViews(in root: NSView) -> [NSScrollView] { + var result: [NSScrollView] = [] + func visit(_ view: NSView) { + if let scrollView = view as? NSScrollView { result.append(scrollView) } + view.subviews.forEach(visit) + } + visit(root) + return result + } + } + + func makeNSView(context: Context) -> ResolverView { + ResolverView(frame: .zero) + } + + func updateNSView(_ nsView: ResolverView, context: Context) { + nsView.configureAfterLayout() + } + } + +#endif diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 40b5512c..c9bb79bb 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -486,7 +486,7 @@ final class MobileAppModel { } unboundRestoredSessionIDs = Set(snapshot.sessions.keys) hasLoadedWorkspaceSnapshot = true - updateWorkspaceActivity() + liveActivities.restore(workspaceActivitySessions) } catch { self.error = error.localizedDescription } @@ -507,7 +507,7 @@ final class MobileAppModel { updateWorkspaceActivity() } - func updateWorkspaceActivity() { + private func updateWorkspaceActivity() { // A failed read is not an authoritative empty list. guard hasLoadedWorkspaceSnapshot else { return } liveActivities.update(workspaceActivitySessions) @@ -612,9 +612,15 @@ final class MobileAppModel { // openSessions reconciliation can establish that its window has actually disappeared. } - func reconcileWorkspaceSessions(openSessionIDs: Set, activeSessionID: String?) { + func reconcileWorkspaceSessions( + openSessionIDs: Set, activeSessionID: String?, supportsMultipleScenes: Bool = true + ) { + // On single-window devices UIKit can still report archived sessions from a previous launch. + // Their workspaces belong in the current list, not in an unreachable Live Activity-only owner. + let ownerIDs = + supportsMultipleScenes ? openSessionIDs : Set([activeSessionID].compactMap { $0 }) orphanedSessionIDs = Set(workspaceSessions.map(\.id)) - .subtracting(openSessionIDs.subtracting(retiredRestoredSessionIDs)).subtracting(discardedSessionIDs) + .subtracting(ownerIDs.subtracting(retiredRestoredSessionIDs)).subtracting(discardedSessionIDs) let target = activeSessionID.flatMap { id in workspaceSessions.first { $0.id == id } } if let target, openSessionIDs.contains(target.id) { recoverOrphanedWorkspaces(into: target) } } diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 6fbc8078..f95f5a90 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -225,36 +225,28 @@ final class MobileSceneModel { } } - /// Reuses this scene's most recently selected preferred terminal workspace for the Host. + /// Reuses this scene's most recently selected terminal workspace for the Host, regardless of transport. /// Explicit connection actions continue to call `open(hostID:kind:)` so /// users can still create independent SSH and SFTP workspaces. func activateHost(_ hostID: Host.ID) { - guard let host = app.library.host(hostID), let connectionProtocol = host.preferredTerminalProtocol else { - return - } - let kind: ConnectionHistoryEntry.Kind = connectionProtocol == .ssh ? .ssh : .telnet + guard let host = app.library.host(hostID) else { return } guard let workspace = workspaceActivatedByHostRow(for: host) else { - open(hostID: hostID, kind: kind) + guard let connectionProtocol = host.preferredTerminalProtocol else { return } + open(hostID: hostID, kind: .terminal(connectionProtocol)) return } selectWorkspace(workspace.id) } /// The connection a Vault Host card will reopen in this window. - func workspaceActivatedByHostRow(for host: Host) -> MobileWorkspace? { - guard let connectionProtocol = host.preferredTerminalProtocol else { return nil } - let kind: ConnectionHistoryEntry.Kind = connectionProtocol == .ssh ? .ssh : .telnet - let matchingIDs = Set( - workspaceSession.workspaces.lazy - .filter { $0.descriptor.hostID == host.id && $0.descriptor.kind == kind } - .map(\.id) - ) - let workspaceID = - recentWorkspaceIDs.first(where: matchingIDs.contains) - ?? workspaceSession.workspaces.last(where: { - $0.descriptor.hostID == host.id && $0.descriptor.kind == kind - })?.id - return workspaceID.flatMap { id in workspaceSession.workspaces.first { $0.id == id } } + func workspaceActivatedByHostRow(for host: Host) -> MobileWorkspaceItem? { + let matchingItems = workspaceSession.orderedWorkspaceItems.filter { + $0.descriptor.hostID == host.id && $0.descriptor.kind.terminalProtocol != nil + } + if let id = recentWorkspaceIDs.first(where: { id in matchingItems.contains { $0.id == id } }) { + return matchingItems.first { $0.id == id } + } + return matchingItems.last } func openSameDirectory(_ launch: MobileWorkspaceDirectoryLaunch) { @@ -448,7 +440,6 @@ final class MobileSceneModel { return } if WorkspaceActivityAttributes.accepts(url) { - app.updateWorkspaceActivity() isWorkspaceUnavailable = false pendingWorkspaceID = nil selectedDestination = .workspaces @@ -464,8 +455,6 @@ final class MobileSceneModel { } return } - // Even an expired link must reconcile system cards against the authoritative local list. - app.updateWorkspaceActivity() isWorkspaceUnavailable = false let id = sourceID guard let owner = app.workspaceOwner(id) else { diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 8f00363c..07d77802 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -789,7 +789,7 @@ struct MobileVaultView: View { MobileHostCardContent( host: host, address: app.library.preferredTerminalLoginAddress(for: host), - status: scene?.workspaceActivatedByHostRow(for: host).flatMap { + status: scene?.workspaceActivatedByHostRow(for: host)?.workspace.flatMap { MobileWorkspaceConnectionIndicator.resolve( connected: $0.connected, busy: $0.busy, reconnecting: $0.reconnectState != nil )?.status diff --git a/TFI/Platform/MobileLiveActivityController.swift b/TFI/Platform/MobileLiveActivityController.swift index 6f0e6420..12da0a7b 100644 --- a/TFI/Platform/MobileLiveActivityController.swift +++ b/TFI/Platform/MobileLiveActivityController.swift @@ -12,7 +12,6 @@ final class MobileLiveActivityController { private let services: MobileLiveActivityServices private var focusedWorkspaceID: UUID? private var pendingStart = false - private var pendingState: WorkspaceActivityAttributes.ContentState? init(enabled: Bool = true, services: MobileLiveActivityServices = .live) { self.enabled = enabled @@ -29,61 +28,50 @@ final class MobileLiveActivityController { update(sessions) } - /// Serialize system calls, but coalesce queued changes so closed workspaces are never recreated. + /// Reconcile system cards only after the local snapshot has loaded successfully. + func restore(_ sessions: [WorkspaceActivityAttributes.Session]) { + schedule(sessions, mayStart: false, restoring: true) + } + + /// All starts, updates and ends share a serial queue, including close-last/open-new races. func start(_ sessions: [WorkspaceActivityAttributes.Session]) { schedule(sessions, mayStart: true) } func update(_ sessions: [WorkspaceActivityAttributes.Session]) { schedule(sessions, mayStart: false) } - private func schedule(_ sessions: [WorkspaceActivityAttributes.Session], mayStart: Bool) { + private func schedule( + _ sessions: [WorkspaceActivityAttributes.Session], mayStart: Bool, restoring: Bool = false + ) { guard enabled else { return } - pendingState = WorkspaceActivityAttributes.ContentState.snapshot( + let state = WorkspaceActivityAttributes.ContentState.snapshot( sessions, focusedID: focusedWorkspaceID) - if sessions.isEmpty { - pendingStart = false - } else if mayStart { - pendingStart = true - } - guard updates == nil else { return } + let previous = updates updates = Task { [weak self] in + await previous?.value guard let self else { return } - while let state = pendingState { - pendingState = nil - await reconcile(state) + if restoring { + for old in services.activities() where old.id != activity?.id { await old.end() } } - updates = nil - } - } - - private func reconcile(_ state: WorkspaceActivityAttributes.ContentState) async { - // ActivityKit can expose old cards after startup. The cached handle is not the full inventory. - var activities = services.activities() - if let activity, !activities.contains(where: { $0.id == activity.id }) { activities.append(activity) } - let usable = activities.filter { $0.state == .active || $0.state == .stale } - let retained = - state.workspaceCount > 0 - ? usable.first(where: { $0.id == activity?.id }) ?? usable.first : nil - activity = retained - if retained != nil { pendingStart = false } - for other in activities where other.id != retained?.id { await other.end() } - - // An open/close may have arrived while dismissal was suspended. Publish only the newest list. - guard pendingState == nil, state.workspaceCount > 0 else { return } - if let activity { - if activity.state == .active || activity.state == .stale { + if mayStart { pendingStart = true } + guard state.workspaceCount > 0 else { + pendingStart = false + if let activity { await activity.end() } + activity = nil + return + } + if let activity, activity.state == .active || activity.state == .stale { + pendingStart = false await activity.update(state) return } - await activity.end() - self.activity = nil - } - // An explicit open may arrive before activation. Retain its intent until a foreground update. - // Once consumed, ordinary updates cannot resurrect a user-dismissed activity. - guard pendingState == nil, pendingStart, services.canStart() else { return } - pendingStart = false - do { - activity = try services.request(state) - error = nil - } catch { - self.error = error.localizedDescription + // An explicit open may arrive before activation. Retain its intent until a foreground update. + // Once consumed, ordinary updates cannot resurrect a user-dismissed activity. + guard pendingStart, services.canStart() else { return } + pendingStart = false + do { + activity = try services.request(state) + error = nil + } catch { + self.error = error.localizedDescription + } } } } diff --git a/TFI/Platform/MobileWorkspaceSceneRouting.swift b/TFI/Platform/MobileWorkspaceSceneRouting.swift index 1ba01c55..5894323a 100644 --- a/TFI/Platform/MobileWorkspaceSceneRouting.swift +++ b/TFI/Platform/MobileWorkspaceSceneRouting.swift @@ -49,8 +49,9 @@ enum MobileWorkspaceSceneRouting { static func reconcile(app: MobileAppModel, activeSessionID: String?) { app.reconcileWorkspaceSessions( - openSessionIDs: Set(UIApplication.shared.openSessions.map(\.persistentIdentifier)), - activeSessionID: activeSessionID) + openSessionIDs: openSessionIDs(), + activeSessionID: activeSessionID, + supportsMultipleScenes: UIApplication.shared.supportsMultipleScenes) } static func activate(sessionID: String, workspaceID: UUID, unavailable: @escaping @MainActor () -> Void) { diff --git a/TFITests/MobileLiveActivityRestorationTests.swift b/TFITests/MobileLiveActivityRestorationTests.swift index 3785e517..d9d65f2a 100644 --- a/TFITests/MobileLiveActivityRestorationTests.swift +++ b/TFITests/MobileLiveActivityRestorationTests.swift @@ -17,7 +17,7 @@ struct MobileLiveActivityRestorationTests { let duplicate = TestActivity() backend.existing = [dismissed, existing, duplicate] let controller = MobileLiveActivityController(services: backend.services) - controller.update([first, focused]) + controller.restore([first, focused]) #expect(await eventually { existing.updates.count == 1 }) #expect(dismissed.ended && duplicate.ended) @@ -64,57 +64,6 @@ struct MobileLiveActivityRestorationTests { #expect(await eventually { existing.ended }) } - @Test func refreshAdoptsLateSystemActivityAndDismissesDuplicates() async { - let backend = Backend() - let controller = MobileLiveActivityController(services: backend.services) - let current = session() - controller.update([current]) - for _ in 0..<20 { await Task.yield() } - let late = TestActivity(state: .stale) - let duplicate = TestActivity() - let ended = TestActivity(state: .ended) - backend.existing = [late, duplicate, ended] - - controller.update([current]) - - #expect(await eventually { late.updates.last?.sessions.map(\.id) == [current.id] }) - #expect(duplicate.ended && ended.ended) - #expect(!late.ended) - #expect(backend.requests.isEmpty) - } - - @Test func emptySnapshotDismissesEveryLateSystemActivity() async { - let backend = Backend() - let controller = MobileLiveActivityController(services: backend.services) - controller.update([]) - for _ in 0..<20 { await Task.yield() } - let late = TestActivity() - let ended = TestActivity(state: .ended) - backend.existing = [late, ended] - - controller.update([]) - - #expect(await eventually { late.ended && ended.ended }) - #expect(backend.requests.isEmpty) - } - - @Test func explicitOpenDismissesEndedCardBeforeReplacingIt() async { - let backend = Backend() - let existing = TestActivity() - backend.existing = [existing] - let controller = MobileLiveActivityController(services: backend.services) - controller.update([session()]) - #expect(await eventually { existing.updates.count == 1 }) - existing.state = .ended - let next = session() - - controller.start([next]) - - #expect(await eventually { backend.requests.count == 1 }) - #expect(existing.ended) - #expect(backend.requests.first?.sessions.map(\.id) == [next.id]) - } - @Test func closeLastThenOpenWaitsForOldActivityToFinishEnding() async { let backend = Backend() let existing = TestActivity() @@ -122,143 +71,21 @@ struct MobileLiveActivityRestorationTests { backend.existing = [existing] let controller = MobileLiveActivityController(services: backend.services) controller.update([]) - #expect(await eventually { existing.endContinuation != nil }) let next = session() controller.start([next]) controller.focus(next.id, in: [next]) + #expect(await eventually { existing.endContinuation != nil }) #expect(backend.requests.isEmpty) existing.endContinuation?.resume() existing.endContinuation = nil - #expect(await eventually { backend.requests.count == 1 }) + #expect(await eventually { backend.created.first?.updates.count == 1 }) #expect(existing.ended) #expect(backend.requests.count == 1) - #expect(backend.requests.first?.focusedWorkspaceID == next.id) + #expect(backend.created.first?.updates.first?.focusedWorkspaceID == next.id) #expect(backend.created.first?.ended == false) } - @Test func rapidOpenCloseWhileEndingOnlyPublishesTheLatestWorkspace() async { - let backend = Backend() - let existing = TestActivity() - existing.suspendsEnd = true - backend.existing = [existing] - let controller = MobileLiveActivityController(services: backend.services) - controller.update([]) - #expect(await eventually { existing.endContinuation != nil }) - for _ in 0..<5 { - controller.start([session()]) - controller.update([]) - } - let latest = session() - controller.start([latest]) - existing.endContinuation?.resume() - existing.endContinuation = nil - - #expect(await eventually { backend.requests.last?.sessions.map(\.id) == [latest.id] }) - #expect(backend.requests.count == 1) - #expect(backend.created.filter { !$0.ended }.count == 1) - } - - @Test func closeDuringDuplicateCleanupDoesNotPublishTheObsoleteOpen() async { - let backend = Backend() - let existing = TestActivity() - let duplicate = TestActivity() - duplicate.suspendsEnd = true - backend.existing = [existing, duplicate] - let controller = MobileLiveActivityController(services: backend.services) - controller.start([session()]) - #expect(await eventually { duplicate.endContinuation != nil }) - - controller.update([]) - duplicate.endContinuation?.resume() - duplicate.endContinuation = nil - - #expect(await eventually { existing.ended && duplicate.ended }) - #expect(existing.updates.isEmpty) - #expect(backend.requests.isEmpty) - } - - @Test func dismissingRetainedCardDuringCleanupDoesNotReplayItsStartIntent() async { - let backend = Backend() - let existing = TestActivity() - let duplicate = TestActivity() - duplicate.suspendsEnd = true - backend.existing = [existing, duplicate] - let controller = MobileLiveActivityController(services: backend.services) - let current = session() - controller.start([current]) - #expect(await eventually { duplicate.endContinuation != nil }) - - existing.state = .dismissed - controller.update([current]) - duplicate.endContinuation?.resume() - duplicate.endContinuation = nil - - #expect(await eventually { existing.ended && duplicate.ended }) - #expect(existing.updates.isEmpty) - #expect(backend.requests.isEmpty) - } - - @Test func staleLinkAfterRepeatedOpenCloseCleansLateCardWithoutRebuilding() async throws { - let backend = Backend() - var dependencies = MobileAppDependencies.testing() - dependencies.liveActivities = MobileLiveActivityController(services: backend.services) - let app = try MobileAppModel(dependencies: dependencies) - let host = TermindDomain.Host(name: "Server", host: "example.invalid") - app.library.upsert(host) - let scene = MobileSceneModel(app: app, sessionID: "window") - var closedID: UUID? - for index in 1...3 { - let id = try #require(scene.workspaceSession.open(hostID: host.id, kind: .ssh)) - closedID = id - #expect(await eventually { backend.requests.count == index }) - scene.closeAllWorkspaces() - #expect(await eventually { backend.created.last?.ended == true }) - } - let id = try #require(closedID) - let late = TestActivity(focusedWorkspaceID: id) - late.contentState = .snapshot( - [.init(id: id, title: "Closed", kind: "ssh", connected: false)], focusedID: id) - backend.existing.append(late) - - scene.open(WorkspaceActivityAttributes.destinationURL(for: id)) - - #expect(scene.isWorkspaceUnavailable) - #expect(await eventually { late.ended }) - #expect(!app.containsWorkspace(id)) - #expect(app.workspaces.isEmpty) - #expect(backend.requests.count == 3) - } - - @Test func staleLinkKeepsUnrelatedSavedWorkspaceAndDoesNotReconnectIt() async throws { - let name = "ActivityStaleLink.\(UUID())" - let defaults = try #require(UserDefaults(suiteName: name)) - defer { defaults.removePersistentDomain(forName: name) } - let descriptor = MobileWorkspaceDescriptor(hostID: UUID(), title: "Unavailable Host", kind: .ssh) - try MobileWorkspaceStore(defaults: defaults).save(.init(sessions: ["window": [descriptor]])) - let backend = Backend() - var dependencies = MobileAppDependencies.testing(defaults: defaults) - dependencies.liveActivities = MobileLiveActivityController(services: backend.services) - let app = try MobileAppModel(dependencies: dependencies) - let scene = MobileSceneModel(app: app, sessionID: "window") - for _ in 0..<20 { await Task.yield() } - let stale = session() - let late = TestActivity(focusedWorkspaceID: stale.id) - late.contentState = .snapshot([stale], focusedID: stale.id) - backend.existing.append(late) - - scene.open(stale.destinationURL) - - #expect(scene.isWorkspaceUnavailable) - #expect(await eventually { late.updates.last?.sessions.map(\.id) == [descriptor.id] }) - #expect(!late.ended) - #expect(scene.workspaceSession.savedWorkspaceDescriptors == [descriptor]) - #expect(app.workspaces.isEmpty) - #expect(backend.requests.isEmpty) - scene.closeAllWorkspaces() - #expect(await eventually { late.ended }) - } - @Test(arguments: [false, true]) func coldLaunchPublishesSavedRecordsAndCanCloseWithoutConnecting(openBeforeClose: Bool) async throws { let name = "ActivityRestoration.\(UUID())" @@ -342,7 +169,7 @@ struct MobileLiveActivityRestorationTests { #expect(defaults.data(forKey: MobileWorkspaceStore.restorationKey) == damaged) } - @Test func explicitOpenIncludesDormantRecordsButLaunchDoesNotRecreateADismissedCard() async throws { + @Test func singleWindowRestoresArchivedOwnersBeforeRecreatingDismissedActivity() async throws { let name = "DormantActivityRecords.\(UUID())" let defaults = try #require(UserDefaults(suiteName: name)) defer { defaults.removePersistentDomain(forName: name) } @@ -353,7 +180,8 @@ struct MobileLiveActivityRestorationTests { MobileWorkspaceDescriptor(hostID: host.id, title: "Shell", kind: .ssh), MobileWorkspaceDescriptor(hostID: host.id, title: "Files", kind: .sftp), ] - try MobileWorkspaceStore(defaults: defaults).save(.init(sessions: ["owner": records])) + let store = MobileWorkspaceStore(defaults: defaults) + try store.save(.init(sessions: ["old-a": [records[0]], "old-b": [records[1]]])) let backend = Backend() backend.existing = [TestActivity(state: .dismissed)] var dependencies = MobileAppDependencies.testing(defaults: defaults) @@ -361,12 +189,18 @@ struct MobileLiveActivityRestorationTests { dependencies.liveActivities = MobileLiveActivityController(services: backend.services) let app = try MobileAppModel(dependencies: dependencies) let scene = MobileSceneModel(app: app, sessionID: "owner") + app.reconcileWorkspaceSessions( + openSessionIDs: ["old-a", "old-b", "owner"], activeSessionID: "owner", supportsMultipleScenes: false) app.sceneChanged(scene.id, phase: .active) #expect(await eventually { backend.existing[0].ended }) #expect(backend.requests.isEmpty) #expect(app.workspaces.isEmpty) + try #require(scene.workspaceSession.savedWorkspaceDescriptors == records) + #expect(try store.load().sessions["owner"] == records) + // A late UIKit discard must not remove records that have already moved into the visible list. + app.discardWorkspaceSessions(["old-a", "old-b"]) - scene.open(WorkspaceActivityAttributes.destinationURL(for: records[0].id)) + scene.selectWorkspace(records[0].id) scene.activatePendingWorkspace() try #require(await eventually { backend.requests.count == 1 }) #expect(backend.requests[0].workspaceCount == 2) @@ -376,8 +210,19 @@ struct MobileLiveActivityRestorationTests { let workspace = try #require(scene.activeWorkspace) #expect(!workspace.sessionPersistence.requiresSelection) #expect(workspace.sessionPersistence.prompt == nil) + let dormantURL = WorkspaceActivityAttributes.destinationURL(for: records[1].id) + scene.open(dormantURL) + #expect(!scene.isWorkspaceUnavailable) + #expect(scene.activeWorkspace?.id == records[1].id) + #expect(app.workspaceOwner(records[1].id) == "owner") + scene.workspaceSession.close(records[0].id) + #expect(await eventually { backend.created[0].updates.last?.sessions.map(\.id) == [records[1].id] }) scene.workspaceSession.closeAll() #expect(await eventually { backend.created[0].ended }) + #expect(try store.load().sessions.values.allSatisfy(\.isEmpty)) + scene.open(dormantURL) + #expect(scene.isWorkspaceUnavailable) + #expect(app.workspaces.isEmpty) #expect(scene.workspaceSession.reopenLastClosed() == records[1].id) try #require(await eventually { backend.requests.count == 2 }) #expect(backend.requests[1].sessions.map(\.id) == [records[1].id]) diff --git a/TFITests/MobileShortcutTests.swift b/TFITests/MobileShortcutTests.swift index b3427712..7201b7ba 100644 --- a/TFITests/MobileShortcutTests.swift +++ b/TFITests/MobileShortcutTests.swift @@ -235,6 +235,35 @@ struct MobileShortcutTests { #expect(scene.activeWorkspace?.descriptor.kind == .ssh) } + @Test(arguments: HostTerminalProtocol.allCases) + func hostCardActivationCreatesAndReusesTheDefaultTerminal(transport: HostTerminalProtocol) throws { + let app = try MobileAppModel(inMemory: true) + var host = Host(name: "Default transport", host: "example.invalid") + host.sshEnabled = transport != .telnet + host.telnetEnabled = true + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + if transport != .telnet { host.terminalTransports.defaultTransport = transport } + app.library.upsert(host) + let scene = MobileSceneModel(app: app, sessionID: "test") + defer { + scene.closeAllWorkspaces() + app.outbox.stop() + } + + scene.activateHost(host.id) + scene.activatePendingWorkspace() + let workspace = try #require(scene.activeWorkspace) + #expect(workspace.descriptor.kind == .terminal(transport)) + #expect(scene.workspaceActivatedByHostRow(for: host)?.workspace === workspace) + + scene.selectedDestination = .vault + scene.activateHost(host.id) + scene.activatePendingWorkspace() + #expect(scene.activeWorkspace === workspace) + #expect(scene.workspaceSession.workspaces.count == 1) + } + @Test func hostCardActivationForgetsAClosedRecentWorkspace() throws { let app = try MobileAppModel(inMemory: true) let host = Host(name: "Test", host: "example.invalid") diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index 19de458d..f9769f33 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -704,13 +704,18 @@ struct MobileWorkspaceSessionTests { #expect(second.isWorkspaceUnavailable) } - @Test func vaultHostIndicatorFollowsTheWorkspaceOpenedByItsCard() throws { + @Test(arguments: HostTerminalProtocol.allCases) + func vaultHostIndicatorFollowsTheWorkspaceOpenedByItsCard(transport: HostTerminalProtocol) throws { let fixture = try Fixture() defer { fixture.cleanUp() } let app = try fixture.makeApp() let scene = MobileSceneModel(app: app, sessionID: "vault-card") let other = MobileSceneModel(app: app, sessionID: "other-window") - let host = fixture.host + var host = fixture.host + host.telnetEnabled = true + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + app.library.upsert(host) other.open(hostID: host.id, kind: .ssh) #expect(scene.workspaceActivatedByHostRow(for: host) == nil) @@ -719,17 +724,66 @@ struct MobileWorkspaceSessionTests { #expect(scene.workspaceActivatedByHostRow(for: host) == nil) scene.open(hostID: host.id, kind: .ssh) - let first = try #require(scene.workspaceActivatedByHostRow(for: host)) - scene.open(hostID: host.id, kind: .ssh) - let second = try #require(scene.workspaceActivatedByHostRow(for: host)) + let first = try #require(scene.workspaceActivatedByHostRow(for: host)?.workspace) + scene.open(hostID: host.id, kind: .terminal(transport)) + let second = try #require(scene.workspaceActivatedByHostRow(for: host)?.workspace) #expect(first !== second) + #expect(second.descriptor.kind == .terminal(transport)) scene.selectWorkspace(first.id) - #expect(scene.workspaceActivatedByHostRow(for: host) === first) + #expect(scene.workspaceActivatedByHostRow(for: host)?.workspace === first) scene.activateHost(host.id) #expect(scene.activeWorkspace === first) } + @Test(arguments: [false, true], HostTerminalProtocol.allCases) + func vaultHostReopensDormantWorkspaceWithoutCreatingADuplicate( + olderWorkspaceIsLive: Bool, transport: HostTerminalProtocol + ) throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let kind = ConnectionHistoryEntry.Kind.terminal(transport) + let saved = MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Saved shell", kind: kind) + var host = fixture.host + host.telnetEnabled = true + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + host.terminalTransports.defaultTransport = transport == .ssh ? .mosh : .ssh + let older = MobileWorkspaceDescriptor( + hostID: host.id, title: "Older default terminal", kind: .terminal(host.terminalTransports.defaultTransport)) + let files = MobileWorkspaceDescriptor(hostID: host.id, title: "Files", kind: .sftp) + let unrelated = MobileWorkspaceDescriptor(hostID: UUID(), title: "Other host", kind: .ssh) + let otherWindow = MobileWorkspaceDescriptor(hostID: host.id, title: "Other window", kind: kind) + let records = [older, saved, files, unrelated] + try MobileWorkspaceStore(defaults: fixture.defaults).save( + .init(sessions: ["vault-card": records, "other-window": [otherWindow]])) + let app = try fixture.makeApp() + app.library.upsert(host) + let scene = MobileSceneModel(app: app, sessionID: "vault-card") + if olderWorkspaceIsLive { + #expect(scene.workspaceSession.openWorkspace(older.id) != nil) + } + + let item = try #require(scene.workspaceActivatedByHostRow(for: host)) + #expect(item.id == saved.id) + #expect(item.workspace == nil) + #expect(app.workspaces.count == (olderWorkspaceIsLive ? 1 : 0)) + + scene.activateHost(host.id) + scene.activatePendingWorkspace() + let reopened = try #require(scene.activeWorkspace) + #expect(reopened.descriptor == saved) + #expect(scene.workspaceSession.orderedWorkspaceItems.map(\.id) == records.map(\.id)) + #expect(scene.workspaceActivatedByHostRow(for: host)?.workspace === reopened) + #expect(app.workspaceSession(for: "other-window").workspaces.isEmpty) + + scene.selectedDestination = .vault + scene.activateHost(host.id) + scene.activatePendingWorkspace() + #expect(scene.activeWorkspace === reopened) + #expect(app.workspaces.count == (olderWorkspaceIsLive ? 2 : 1)) + } + @Test func everyCreationEntryPointUsesTheReceivingWindow() throws { let fixture = try Fixture() defer { fixture.cleanUp() } diff --git a/TFM/Features/AI/ConversationViews.swift b/TFM/Features/AI/ConversationViews.swift index 051b2583..a1c82080 100644 --- a/TFM/Features/AI/ConversationViews.swift +++ b/TFM/Features/AI/ConversationViews.swift @@ -91,71 +91,6 @@ struct TextViewMarkedTextObserver: NSViewRepresentable { } } -/// Makes AppKit hide the TextEditor's vertical scroller while its document fits. -/// -/// SwiftUI's scroll-indicator visibility does not remove the disabled scroller -/// from a macOS TextEditor when the system is configured to always show scroll -/// bars. The underlying NSScrollView already knows whether its document exceeds -/// the clip view, so leave that decision to `autohidesScrollers` instead of -/// duplicating it from the composer's approximate text-height measurement. -struct TextEditorScrollViewConfigurator: NSViewRepresentable { - final class ResolverView: NSView { - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - configureAfterLayout() - } - - override func layout() { - super.layout() - configureScrollView() - } - - func configureAfterLayout() { - Task { @MainActor [weak self] in self?.configureScrollView() } - } - - private func configureScrollView() { - guard let contentView = window?.contentView, !bounds.isEmpty, - let scrollView = textEditorScrollView(in: contentView) - else { return } - - guard !scrollView.autohidesScrollers || !scrollView.hasVerticalScroller else { return } - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - scrollView.tile() - } - - private func textEditorScrollView(in root: NSView) -> NSScrollView? { - let targetFrame = convert(bounds, to: root) - let targetCenter = NSPoint(x: targetFrame.midX, y: targetFrame.midY) - return descendantScrollViews(in: root).first { scrollView in - guard let textView = scrollView.documentView as? NSTextView, textView.isEditable else { - return false - } - return scrollView.convert(scrollView.bounds, to: root).contains(targetCenter) - } - } - - private func descendantScrollViews(in root: NSView) -> [NSScrollView] { - var result: [NSScrollView] = [] - func visit(_ view: NSView) { - if let scrollView = view as? NSScrollView { result.append(scrollView) } - view.subviews.forEach(visit) - } - visit(root) - return result - } - } - - func makeNSView(context: Context) -> ResolverView { - ResolverView(frame: .zero) - } - - func updateNSView(_ nsView: ResolverView, context: Context) { - nsView.configureAfterLayout() - } -} - // MARK: - Context chips /// The row of user-visible attachments sent to the LLM endpoint. @@ -661,6 +596,7 @@ struct ComposerView: View { /// at one comfortable line and grows only as the draft does. private func draftEditor(_ draft: Binding) -> some View { TextEditor(text: draft) + .autohidingScrollers() .font(.body) .scrollContentBackground(.hidden) .focused($isFocused) @@ -693,7 +629,6 @@ struct ComposerView: View { paste: { isFocused && handleContextPaste() } ) } - .background { TextEditorScrollViewConfigurator() } .onPasteCommand(of: [.fileURL, .image, .text]) { _ in if !handleContextPaste() { insertShortPasteIntoEditor() } } diff --git a/TFM/Features/Keychain/KeychainPageView.swift b/TFM/Features/Keychain/KeychainPageView.swift index e1a4cbb9..c7d8b429 100644 --- a/TFM/Features/Keychain/KeychainPageView.swift +++ b/TFM/Features/Keychain/KeychainPageView.swift @@ -754,6 +754,7 @@ private struct SoftwareKeyEditor: View { text: Binding, minHeight: CGFloat, placeholder: LocalizedStringResource ) -> some View { TextEditor(text: text) + .autohidingScrollers() .font(.caption.monospaced()) .frame(minHeight: minHeight) .scrollContentBackground(.hidden) diff --git a/TFM/Features/Settings/SkillEditorSheet.swift b/TFM/Features/Settings/SkillEditorSheet.swift index 42fa6dde..ced3fdbd 100644 --- a/TFM/Features/Settings/SkillEditorSheet.swift +++ b/TFM/Features/Settings/SkillEditorSheet.swift @@ -140,6 +140,7 @@ struct SkillEditorPage: View { private func descriptionSection(_ skill: AgentSkill) -> some View { Section { TextEditor(text: binding(\.summary)) + .autohidingScrollers() .font(.callout) .frame(minHeight: 60) .scrollContentBackground(.hidden) @@ -170,6 +171,7 @@ struct SkillEditorPage: View { private func bodySection(_ skill: AgentSkill) -> some View { Section { TextEditor(text: binding(\.body)) + .autohidingScrollers() .font(.callout.monospaced()) .frame(minHeight: 220) .scrollContentBackground(.hidden) diff --git a/TFM/Features/Settings/TerminalCustomConfigurationEditorPage.swift b/TFM/Features/Settings/TerminalCustomConfigurationEditorPage.swift index c2576228..36c8b961 100644 --- a/TFM/Features/Settings/TerminalCustomConfigurationEditorPage.swift +++ b/TFM/Features/Settings/TerminalCustomConfigurationEditorPage.swift @@ -15,6 +15,7 @@ struct TerminalCustomConfigurationEditorPage: View { @State private var importError: String? @State private var fieldsHeight: CGFloat = 0 @State private var headingHeight: CGFloat = 0 + @State private var footerHeight: CGFloat = 0 init( draft: TerminalCustomThemeDraft, model: TerminalSettingsModel, availableFontFamilies: [String] = [] @@ -29,7 +30,7 @@ struct TerminalCustomConfigurationEditorPage: View { GeometryReader { geometry in let contentWidth = geometry.size.width - 8 let sideBySide = contentWidth >= 700 && !typeSize.isAccessibilitySize - let panelHeight = max(460, geometry.size.height - fieldsHeight - 24) + let panelHeight = max(460, geometry.size.height - fieldsHeight - footerHeight - 40) let layout = sideBySide ? AnyLayout(HStackLayout(alignment: .top, spacing: 16)) @@ -52,6 +53,7 @@ struct TerminalCustomConfigurationEditorPage: View { headingHeight = $0 } TextEditor(text: $draft.value.configuration) + .autohidingScrollers() .font(.system(.body, design: .monospaced)) .textContentType(.none) .autocorrectionDisabled() @@ -74,6 +76,14 @@ struct TerminalCustomConfigurationEditorPage: View { .frame(maxWidth: .infinity) .frame(height: sideBySide ? panelHeight : nil) } + Text(AppStrings.Settings.terminalConfigurationExplanation) + .font(.caption).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self) { + $0.size.height + } action: { + footerHeight = $0 + } } // Native text-field focus rings extend outside the control's layout bounds. .padding(4) @@ -163,9 +173,6 @@ struct TerminalCustomConfigurationEditorPage: View { if let error = importError ?? model.storageError { Text(error).font(.caption).foregroundStyle(.red).textSelection(.enabled) } - Text(AppStrings.Settings.terminalConfigurationExplanation) - .font(.caption).foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/TFMTests/ChatComposerStateTests.swift b/TFMTests/ChatComposerStateTests.swift index 3bb94712..f525eab4 100644 --- a/TFMTests/ChatComposerStateTests.swift +++ b/TFMTests/ChatComposerStateTests.swift @@ -100,6 +100,8 @@ struct ChatComposerStateTests { defer { window.close() } let emptyScroller = try await requireComposerScroller(in: hosting) + emptyScroller.scrollerStyle = .legacy + emptyScroller.tile() #expect(emptyScroller.autohidesScrollers) #expect(emptyScroller.hasVerticalScroller) #expect(emptyScroller.verticalScroller?.isHidden == true) @@ -110,6 +112,7 @@ struct ChatComposerStateTests { $0.verticalScroller?.isEnabled == true } #expect(overflowingScroller.autohidesScrollers) + #expect(overflowingScroller.verticalScroller?.isHidden == false) state.text = "Short again" let shortScroller = try await requireComposerScroller(in: hosting) { @@ -2446,7 +2449,7 @@ private struct ComposerScrollerTestView: View { var body: some View { TextEditor(text: $state.text) - .background { TextEditorScrollViewConfigurator() } + .autohidingScrollers() } } diff --git a/TFMTests/TerminalCustomConfigurationEditorLayoutTests.swift b/TFMTests/TerminalCustomConfigurationEditorLayoutTests.swift index c9686e4b..828cd45c 100644 --- a/TFMTests/TerminalCustomConfigurationEditorLayoutTests.swift +++ b/TFMTests/TerminalCustomConfigurationEditorLayoutTests.swift @@ -41,6 +41,7 @@ struct TerminalCustomConfigurationEditorLayoutTests { let editor = try #require(fixture.descendant(NSTextView.self)) let scroll = try #require(editor.enclosingScrollView) let preview = try #require(fixture.descendant(AppTerminalView.self)) + scroll.scrollerStyle = .legacy let editorHeight = scroll.bounds.height let previewHeight = preview.bounds.height #expect(editorHeight > 400) @@ -54,11 +55,16 @@ struct TerminalCustomConfigurationEditorLayoutTests { #expect(abs(scroll.bounds.height - editorHeight) <= 1) #expect(abs(preview.bounds.height - previewHeight) <= 1) fixture.expectFillsPage(editor: scroll, preview: preview) + #expect(scroll.autohidesScrollers) if text == Self.longConfiguration { + #expect(scroll.verticalScroller?.isHidden == false) + #expect(scroll.verticalScroller?.isEnabled == true) #expect(editor.bounds.height > scroll.contentSize.height) editor.scrollRangeToVisible(NSRange(location: (text as NSString).length, length: 0)) #expect(scroll.contentView.bounds.minY > 0) #expect(abs(scroll.bounds.height - editorHeight) <= 1) + } else { + #expect(scroll.verticalScroller?.isHidden == true) } } #expect(!fixture.window.isVisible) @@ -139,9 +145,11 @@ struct TerminalCustomConfigurationEditorLayoutTests { let draft: TerminalCustomThemeDraft let controller: NSHostingController let window: NSWindow + let language: String var view: NSView { controller.view } init(language: String = "en", isDark: Bool = true, typeSize: DynamicTypeSize = .large) { + self.language = language let theme = TerminalCustomTheme( name: "Layout", appearance: isDark ? .dark : .light, baseThemeName: isDark ? "Termind Dark" : "Termind Light") @@ -187,9 +195,17 @@ struct TerminalCustomConfigurationEditorLayoutTests { func expectFillsPage(editor: NSScrollView, preview: AppTerminalView) { let editorFrame = editor.convert(editor.bounds, to: view) let previewFrame = preview.convert(preview.bounds, to: view) + let footer = NSHostingView( + rootView: Text(AppStrings.Settings.terminalConfigurationExplanation) + .font(.caption) + .frame(width: view.bounds.width - 32, alignment: .leading) + .environment(\.locale, Locale(identifier: language))) + let footerHeight = footer.fittingSize.height let editorBottom = view.isFlipped ? view.bounds.maxY - editorFrame.maxY : editorFrame.minY let previewBottom = view.isFlipped ? view.bounds.maxY - previewFrame.maxY : previewFrame.minY - #expect(abs(editorBottom - 16) <= 1) + // Leave the localized footer, its spacing, and the page inset below both panels. + #expect(footerHeight > 0) + #expect(abs(editorBottom - footerHeight - 32) <= 1) // The terminal and color specimens share one panel; specimens sit below the native surface. #expect(previewBottom > editorBottom) #expect(previewFrame.height >= 220) diff --git a/TFMTests/TextEditorScrollerTests.swift b/TFMTests/TextEditorScrollerTests.swift new file mode 100644 index 00000000..3d0f9786 --- /dev/null +++ b/TFMTests/TextEditorScrollerTests.swift @@ -0,0 +1,95 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing + +@testable import Termind + +@Suite(.serialized) @MainActor +struct TextEditorScrollerTests { + @Test + func snippetScriptHidesItsUnusedScrollerAsTheFormGrows() async throws { + let state = ScriptState() + let fixture = Fixture( + Form { + SnippetScriptSection(script: Binding(get: { state.text }, set: { state.text = $0 }), minHeight: 180) { + Text("Assessment") + } + } + .formStyle(.grouped)) + defer { fixture.window.close() } + await fixture.layout() + let scroll = try #require(fixture.editorScrollViews.first) + scroll.scrollerStyle = .legacy + + for text in ["", "echo hello", Array(repeating: "echo hello", count: 80).joined(separator: "\n"), ""] { + state.text = text + await fixture.layout() + let editor = try #require(scroll.documentView as? NSTextView) + #expect(editor.string == text) + #expect(scroll.autohidesScrollers) + let overflows = editor.bounds.height > scroll.contentSize.height + 1 + #expect(scroll.verticalScroller?.isHidden == !overflows) + } + #expect(!fixture.window.isVisible) + } + + @Test + func keyImportHidesUnusedScrollersInAllThreeFields() async throws { + let app = AppModel(hosts: HostLibraryModel()) + let fixture = Fixture( + KeychainImportDetail() + .environment(app) + .environment(WindowModel(workspaces: [])) + .environment(VaultWorkEditingSession())) + defer { + fixture.window.close() + app.shutdownForTermination() + } + await fixture.layout() + let scrollViews = fixture.editorScrollViews + #expect(scrollViews.count == 3) + for scroll in scrollViews { scroll.scrollerStyle = .legacy } + await fixture.layout() + for scroll in scrollViews { + #expect((scroll.documentView as? NSTextView)?.string.isEmpty == true) + #expect(scroll.autohidesScrollers) + #expect(scroll.verticalScroller?.isHidden == true) + } + #expect(!fixture.window.isVisible) + } + + @Observable final class ScriptState { + var text = "" + } + + private final class Fixture { + let view: NSHostingView + let window: NSWindow + + init(_ content: some View) { + view = NSHostingView(rootView: AnyView(content)) + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 900), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + } + + func layout() async { + view.layoutSubtreeIfNeeded() + try? await Task.sleep(for: .milliseconds(150)) + view.layoutSubtreeIfNeeded() + } + + var editorScrollViews: [NSScrollView] { + func find(in root: NSView) -> [NSScrollView] { + if let scroll = root as? NSScrollView, scroll.documentView is NSTextView { return [scroll] } + return root.subviews.flatMap { find(in: $0) } + } + return find(in: view) + } + } +} diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 1848eb57..7e0c1eee 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2033,7 +2033,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 87; + CURRENT_PROJECT_VERSION = 89; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; GENERATE_INFOPLIST_FILE = YES; ICLOUD_CONTAINER_ENVIRONMENT = Development; @@ -2162,7 +2162,7 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 87; + CURRENT_PROJECT_VERSION = 89; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TFIWidgets/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Termind Workspaces"; @@ -2345,7 +2345,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 87; + CURRENT_PROJECT_VERSION = 89; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; GENERATE_INFOPLIST_FILE = YES; ICLOUD_CONTAINER_ENVIRONMENT = Production; @@ -2384,7 +2384,7 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 87; + CURRENT_PROJECT_VERSION = 89; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TFIWidgets/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Termind Workspaces"; @@ -2481,7 +2481,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 63; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = F6SUNDBBN9; @@ -2551,7 +2551,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 63; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = F6SUNDBBN9; @@ -2597,7 +2597,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 98; + CURRENT_PROJECT_VERSION = 100; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; @@ -2635,7 +2635,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 98; + CURRENT_PROJECT_VERSION = 100; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "";