fix: ui and live activity

This commit is contained in:
Kaede Akino committed 2026-10-17 18:39:00 +08:00
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
+10 -4
View File
@@ -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) }
}
+12 -23
View File
@@ -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 {
+1 -1
View File
@@ -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
+33 -45
View File
@@ -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) {
+25 -180
View File
@@ -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])
+29
View File
@@ -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")
+60 -6
View File
@@ -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() }
+1 -66
View File
@@ -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)
}
}
+4 -1
View File
@@ -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)
+95
View File
@@ -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)
}
}
}
+8 -8
View File
@@ -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 = "";