fix: ui and live activity
This commit is contained in:
1 parent
cbda3f0712
commit
9f41e09e12
23 files changed
+402
-345
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -38,6 +38,7 @@ public struct SnippetScriptSection<Assessment: View>: View {
|
||||
public var body: some View {
|
||||
Section(AppStrings.Snippets.script.swiftUIKey) {
|
||||
TextEditor(text: $script)
|
||||
.autohidingScrollers()
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.body.monospaced())
|
||||
.frame(minHeight: minHeight)
|
||||
|
||||
@@ -21,7 +21,7 @@ extension AppStrings {
|
||||
public static var privacyPolicy: LocalizedStringResource {
|
||||
"Privacy Policy"
|
||||
}
|
||||
|
||||
|
||||
public static var termindPro: LocalizedStringResource {
|
||||
"Termind Pro"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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<String>, activeSessionID: String?) {
|
||||
func reconcileWorkspaceSessions(
|
||||
openSessionIDs: Set<String>, 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) }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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<String>) -> 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() }
|
||||
}
|
||||
|
||||
@@ -754,6 +754,7 @@ private struct SoftwareKeyEditor: View {
|
||||
text: Binding<String>, minHeight: CGFloat, placeholder: LocalizedStringResource
|
||||
) -> some View {
|
||||
TextEditor(text: text)
|
||||
.autohidingScrollers()
|
||||
.font(.caption.monospaced())
|
||||
.frame(minHeight: minHeight)
|
||||
.scrollContentBackground(.hidden)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AnyView>
|
||||
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)
|
||||
|
||||
@@ -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<AnyView>
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in new issue
Block a user