Remove useless code
This commit is contained in:
1 parent
d757d4eb2b
commit
f1796d879a
44 files changed
+139
-786
No files matched your search
@@ -11,11 +11,10 @@ struct PortForwardingEditorPolicyTests {
|
||||
func onlySavedStoppedRulesCanStart(hasSavedConfiguration: Bool, isActive: Bool) {
|
||||
let host = Host(name: "Server", host: "server.invalid")
|
||||
let rule = PortForwardingRule(hostID: host.id)
|
||||
let policy = PortForwardingEditorPolicy(
|
||||
rule: rule, savedRule: hasSavedConfiguration ? rule : nil, savedHost: host,
|
||||
isRuntimeActive: isActive)
|
||||
#expect(policy.canStart == (hasSavedConfiguration && !isActive))
|
||||
#expect(policy.canEdit == !isActive)
|
||||
#expect(
|
||||
PortForwardingEditorPolicy.canStart(
|
||||
rule.id, savedRule: hasSavedConfiguration ? rule : nil, savedHost: host, isRuntimeActive: isActive)
|
||||
== (hasSavedConfiguration && !isActive))
|
||||
}
|
||||
|
||||
enum UnavailableConfiguration: CaseIterable {
|
||||
@@ -34,13 +33,9 @@ struct PortForwardingEditorPolicyTests {
|
||||
case .mismatchedHost: savedHost = Host(name: "Other", host: "other.invalid")
|
||||
case .mismatchedRuleID: savedRule?.id = UUID()
|
||||
}
|
||||
let policy = PortForwardingEditorPolicy(
|
||||
rule: rule, savedRule: savedRule, savedHost: savedHost,
|
||||
isRuntimeActive: false)
|
||||
|
||||
#expect(!policy.hasSavedConfiguration)
|
||||
#expect(!policy.canStart)
|
||||
#expect(policy.canEdit)
|
||||
#expect(
|
||||
!PortForwardingEditorPolicy.canStart(
|
||||
rule.id, savedRule: savedRule, savedHost: savedHost, isRuntimeActive: false))
|
||||
}
|
||||
|
||||
@Test(arguments: [0, 65_536])
|
||||
@@ -49,12 +44,8 @@ struct PortForwardingEditorPolicyTests {
|
||||
let draft = PortForwardingRule(hostID: host.id)
|
||||
var savedRule = draft
|
||||
savedRule.bindPort = invalidPort
|
||||
let policy = PortForwardingEditorPolicy(
|
||||
rule: PortForwardingEditorPolicy.normalized(draft), savedRule: savedRule, savedHost: host,
|
||||
isRuntimeActive: false)
|
||||
|
||||
#expect(!policy.hasSavedConfiguration)
|
||||
#expect(!policy.canStart)
|
||||
#expect(policy.canEdit)
|
||||
#expect(
|
||||
!PortForwardingEditorPolicy.canStart(
|
||||
draft.id, savedRule: savedRule, savedHost: host, isRuntimeActive: false))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import AgentKit
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
|
||||
/// What Termind, specifically, tells its agent.
|
||||
///
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
|
||||
/// Server-authored authentication data. Neither challenges nor answers belong
|
||||
/// in an Agent transcript, connection log, or persistent credential store.
|
||||
|
||||
@@ -46,7 +46,6 @@ public final class ForwardingLibraryModel {
|
||||
public func portForwarding(_ id: PortForwardingRule.ID) -> PortForwardingRule? {
|
||||
library.portForwarding(id)
|
||||
}
|
||||
public func routeSummary(for host: Host) -> String { library.routeSummary(for: host) }
|
||||
public func sshCommand(for rule: PortForwardingRule, host: Host? = nil) -> String? {
|
||||
library.sshCommand(for: rule, host: host)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import AgentKit
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
|
||||
@Observable @MainActor
|
||||
public final class MemoryModel {
|
||||
|
||||
@@ -3,23 +3,12 @@ import TermindDomain
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
/// Shared edit and start policy for the platform-specific forwarding editors.
|
||||
public nonisolated struct PortForwardingEditorPolicy {
|
||||
public let hasSavedConfiguration: Bool
|
||||
public let isRuntimeActive: Bool
|
||||
|
||||
public var canStart: Bool { hasSavedConfiguration && !isRuntimeActive }
|
||||
public var canEdit: Bool { !isRuntimeActive }
|
||||
|
||||
public init(
|
||||
rule: PortForwardingRule?,
|
||||
savedRule: PortForwardingRule?, savedHost: Host?,
|
||||
isRuntimeActive: Bool
|
||||
) {
|
||||
hasSavedConfiguration =
|
||||
savedRule?.isValid == true && savedRule?.hostID == savedHost?.id
|
||||
&& (rule == nil || rule?.id == savedRule?.id)
|
||||
self.isRuntimeActive = isRuntimeActive
|
||||
/// Shared validation and start policy for the platform-specific forwarding editors.
|
||||
public nonisolated enum PortForwardingEditorPolicy {
|
||||
public static func canStart(
|
||||
_ id: PortForwardingRule.ID, savedRule: PortForwardingRule?, savedHost: Host?, isRuntimeActive: Bool
|
||||
) -> Bool {
|
||||
savedRule?.id == id && savedRule?.isValid == true && savedRule?.hostID == savedHost?.id && !isRuntimeActive
|
||||
}
|
||||
|
||||
public static func normalized(_ rule: PortForwardingRule) -> PortForwardingRule? {
|
||||
|
||||
@@ -25,7 +25,7 @@ nonisolated enum CommandRiskRules {
|
||||
switch name {
|
||||
case "rm": return classifyRemove(args)
|
||||
case "find": return classifyFind(args)
|
||||
case "chmod", "chown", "chgrp": return classifyPermissions(name, args)
|
||||
case "chmod", "chown", "chgrp": return classifyPermissions(args)
|
||||
case "awk": return caution(name)
|
||||
case "sort":
|
||||
return hasFlag(args, ["-o", "--output", "--compress-program"]) ? caution(name) : .safe
|
||||
@@ -157,7 +157,7 @@ nonisolated enum CommandRiskRules {
|
||||
return .safe
|
||||
}
|
||||
|
||||
private static func classifyPermissions(_ name: String, _ args: [String]) -> RiskAssessment {
|
||||
private static func classifyPermissions(_ args: [String]) -> RiskAssessment {
|
||||
if shortFlags(args).contains("R") {
|
||||
return RiskAssessment(
|
||||
risk: .destructive,
|
||||
|
||||
@@ -159,13 +159,13 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
|
||||
case .accountChange(let change):
|
||||
try lifecycle.handleAccountChange(change.changeType)
|
||||
case .fetchedRecordZoneChanges(let changes):
|
||||
try handleFetchedChanges(changes, engine: engine)
|
||||
try handleFetchedChanges(changes)
|
||||
case .fetchedDatabaseChanges(let changes):
|
||||
if changes.deletions.contains(where: { $0.zoneID == zoneID }) {
|
||||
try lifecycle.handleZoneDeletion()
|
||||
}
|
||||
case .sentRecordZoneChanges(let changes):
|
||||
try handleSentChanges(changes, engine: engine)
|
||||
try handleSentChanges(changes)
|
||||
case .didFetchChanges, .didSendChanges:
|
||||
try lifecycle.updateUpToDateStatus()
|
||||
default:
|
||||
@@ -177,9 +177,7 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFetchedChanges(
|
||||
_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges, engine: CKSyncEngine
|
||||
) throws {
|
||||
private func handleFetchedChanges(_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges) throws {
|
||||
var recordsByKey: [MetricsRecordKey: CKRecord] = [:]
|
||||
var envelopes: [MetricsEnvelope] = []
|
||||
var quarantinedError: String?
|
||||
@@ -217,9 +215,7 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
|
||||
if let quarantinedError { status = .failed(quarantinedError) }
|
||||
}
|
||||
|
||||
private func handleSentChanges(
|
||||
_ changes: CKSyncEngine.Event.SentRecordZoneChanges, engine: CKSyncEngine
|
||||
) throws {
|
||||
private func handleSentChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) throws {
|
||||
for record in changes.savedRecords where record.recordID.zoneID == zoneID {
|
||||
guard let envelope = try? Self.envelope(from: record) else { continue }
|
||||
try journal.markUploaded(envelope.key, version: envelope.version)
|
||||
|
||||
@@ -209,7 +209,7 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
|
||||
try lifecycle.handleAccountChange(change.changeType)
|
||||
|
||||
case .fetchedRecordZoneChanges(let changes):
|
||||
try handleFetchedChanges(changes, engine: engine)
|
||||
try handleFetchedChanges(changes)
|
||||
|
||||
case .fetchedDatabaseChanges(let changes):
|
||||
if changes.deletions.contains(where: { $0.zoneID == zoneID }) {
|
||||
@@ -217,7 +217,7 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
|
||||
}
|
||||
|
||||
case .sentRecordZoneChanges(let changes):
|
||||
try handleSentChanges(changes, engine: engine)
|
||||
try handleSentChanges(changes)
|
||||
|
||||
case .didFetchChanges, .didSendChanges:
|
||||
try lifecycle.updateUpToDateStatus()
|
||||
@@ -231,9 +231,7 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFetchedChanges(
|
||||
_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges, engine: CKSyncEngine
|
||||
) throws {
|
||||
private func handleFetchedChanges(_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges) throws {
|
||||
var recordsByKey: [SettingsRecordKey: CKRecord] = [:]
|
||||
var legacyGuardianKeys = Set<SettingsRecordKey>()
|
||||
var envelopes: [SettingsEnvelope] = []
|
||||
@@ -294,9 +292,7 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
|
||||
if let quarantinedError { status = .failed(quarantinedError) }
|
||||
}
|
||||
|
||||
private func handleSentChanges(
|
||||
_ changes: CKSyncEngine.Event.SentRecordZoneChanges, engine: CKSyncEngine
|
||||
) throws {
|
||||
private func handleSentChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) throws {
|
||||
for record in changes.savedRecords where record.recordID.zoneID == zoneID {
|
||||
guard let envelope = try? Self.envelope(from: record) else { continue }
|
||||
try journal.markUploaded(envelope.key, version: envelope.version)
|
||||
|
||||
@@ -169,7 +169,7 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
|
||||
try lifecycle.handleAccountChange(change.changeType)
|
||||
|
||||
case .fetchedRecordZoneChanges(let changes):
|
||||
try handleFetchedChanges(changes, engine: engine)
|
||||
try handleFetchedChanges(changes)
|
||||
|
||||
case .fetchedDatabaseChanges(let changes):
|
||||
if changes.deletions.contains(where: { $0.zoneID == zoneID }) {
|
||||
@@ -177,7 +177,7 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
|
||||
}
|
||||
|
||||
case .sentRecordZoneChanges(let changes):
|
||||
try handleSentChanges(changes, engine: engine)
|
||||
try handleSentChanges(changes)
|
||||
|
||||
case .didFetchChanges, .didSendChanges:
|
||||
try lifecycle.updateUpToDateStatus()
|
||||
@@ -191,9 +191,7 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFetchedChanges(
|
||||
_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges, engine: CKSyncEngine
|
||||
) throws {
|
||||
private func handleFetchedChanges(_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges) throws {
|
||||
var recordsByKey: [VaultRecordKey: CKRecord] = [:]
|
||||
var envelopes: [VaultEnvelope] = []
|
||||
var quarantinedError: String?
|
||||
@@ -239,9 +237,7 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
|
||||
if let quarantinedError { status = .failed(quarantinedError) }
|
||||
}
|
||||
|
||||
private func handleSentChanges(
|
||||
_ changes: CKSyncEngine.Event.SentRecordZoneChanges, engine: CKSyncEngine
|
||||
) throws {
|
||||
private func handleSentChanges(_ changes: CKSyncEngine.Event.SentRecordZoneChanges) throws {
|
||||
for record in changes.savedRecords where record.recordID.zoneID == zoneID {
|
||||
guard let envelope = try? Self.envelope(from: record) else { continue }
|
||||
try journal.markUploaded(envelope.key, version: envelope.version)
|
||||
|
||||
@@ -84,10 +84,9 @@ public struct PortForwardingFields: View {
|
||||
|
||||
}
|
||||
|
||||
public struct PortForwardingDirectionPicker: View {
|
||||
@Binding private var kind: PortForwardingRule.Kind
|
||||
public init(kind: Binding<PortForwardingRule.Kind>) { _kind = kind }
|
||||
public var body: some View {
|
||||
private struct PortForwardingDirectionPicker: View {
|
||||
@Binding var kind: PortForwardingRule.Kind
|
||||
var body: some View {
|
||||
Picker("Direction", selection: $kind) {
|
||||
Text("Local").tag(PortForwardingRule.Kind.local).accessibilityLabel("Local Forward")
|
||||
Text("Remote").tag(PortForwardingRule.Kind.remote).accessibilityLabel("Remote Forward")
|
||||
|
||||
@@ -35,10 +35,6 @@ public actor SSHForwardingSession: SSHForwardingSessionProtocol {
|
||||
)
|
||||
}
|
||||
|
||||
public func connect(_ configuration: SSHConnectionConfiguration) async throws {
|
||||
try await connect(SSHConnectionRequest(configuration: configuration))
|
||||
}
|
||||
|
||||
public func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler? = nil) async throws {
|
||||
try await worker.connect(request, progress: progress)
|
||||
}
|
||||
|
||||
@@ -494,3 +494,21 @@ secondary action and layout parameter. Keep Connect/Return as the single confirm
|
||||
Workspace for one or more selected SSH/SFTP Hosts. Remove the redundant button/policy/help and three unused localized
|
||||
entries; retain shared BatchLayout for Host context menus. Estimate −45–60 chooser lines; actual −53 including stable
|
||||
accessibility identifiers for behavioral UI coverage. Focus/menu changes are tracked by the same regression run.
|
||||
|
||||
## Port Forwarding refactor, 2026-09-19 — completed
|
||||
|
||||
`rg` plus Periphery on TFM/TFI confirmed the removable surface. Removed the unused forwarding-library route wrapper
|
||||
and configuration-only session overload, made the direction picker private, and replaced the stateful editor policy
|
||||
with static normalization/start validation that retains rule-ID, Host-ID, saved validity and runtime checks. Shared
|
||||
the otherwise identical Port Forwarding/Known Hosts deletion, pruning and error presentation through one typed
|
||||
managed-card modifier; feature state and transactions remain local. Actual change: −35 production and −73 test lines.
|
||||
|
||||
Controller bookkeeping consolidation was rejected: one record would have to encode active generation, stopped-but-
|
||||
serializing work and failed-but-presented Journey state, plus an identity token to safely retire completed tasks after
|
||||
rapid stop/start. The concrete shape adds lifecycle state without reducing code, so the existing dictionaries remain.
|
||||
|
||||
Focused 53 tests passed: Framework forwarding/editor/Known Host presentation, desktop forwarding/editor/Journey/
|
||||
selection, and mobile forwarding/Vault lifecycle. TFM and TFI pinned builds passed. The ordinary TFM test build is
|
||||
currently blocked by unrelated concurrent `ColorSystemTests` and `BroadcastInputTests` API edits; the 29 desktop tests
|
||||
passed when only those two files were excluded. Formatting, localization and diff checks passed. Retained boundaries:
|
||||
forwarding/jump pumps, frozen schemas, forwarding authentication/Journey ownership and platform-native card renderers.
|
||||
@@ -259,8 +259,6 @@ final class MobileAssistantSceneModel {
|
||||
var speech: SpeechTranscriptionCoordinator { runtime.speech }
|
||||
var configuration: AIConfigurationModel { runtime.configuration }
|
||||
var skills: SkillLibraryModel { runtime.skills }
|
||||
var isRunning: Bool { runtime.isRunning }
|
||||
var isSuspending: Bool { runtime.isSuspending }
|
||||
var error: String? {
|
||||
get { runtime.error }
|
||||
set { runtime.error = newValue }
|
||||
|
||||
@@ -94,10 +94,9 @@ struct MobileForwardingView: View {
|
||||
}
|
||||
}
|
||||
private func canStart(_ rule: PortForwardingRule) -> Bool {
|
||||
PortForwardingEditorPolicy(
|
||||
rule: nil, savedRule: rule, savedHost: app.library.host(rule.hostID),
|
||||
isRuntimeActive: app.forwardings.state(for: rule.id).isActive
|
||||
).canStart
|
||||
PortForwardingEditorPolicy.canStart(
|
||||
rule.id, savedRule: rule, savedHost: app.library.host(rule.hostID),
|
||||
isRuntimeActive: app.forwardings.state(for: rule.id).isActive)
|
||||
}
|
||||
private func start(_ rule: PortForwardingRule) {
|
||||
showing = rule.id
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum DeviceCensorship {
|
||||
static let canDisplayTaiwanFlag: Bool = detectTaiwanFlagColor()
|
||||
|
||||
static func containsColoredPixel(_ bytes: [UInt8], bytesPerPixel: Int = 4) -> Bool {
|
||||
guard bytesPerPixel >= 3, bytes.count >= bytesPerPixel else { return false }
|
||||
return stride(from: 0, through: bytes.count - bytesPerPixel, by: bytesPerPixel).contains { offset in
|
||||
let red = bytes[offset]
|
||||
let green = bytes[offset + 1]
|
||||
let blue = bytes[offset + 2]
|
||||
return red != green || green != blue
|
||||
}
|
||||
}
|
||||
|
||||
private static func detectTaiwanFlagColor() -> Bool {
|
||||
let text = "🇹🇼" as NSString
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: UIFont.systemFont(ofSize: 16)
|
||||
]
|
||||
let size = text.size(withAttributes: attributes)
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.opaque = false
|
||||
format.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(
|
||||
size: CGSize(width: max(1, ceil(size.width)), height: max(1, ceil(size.height))),
|
||||
format: format
|
||||
)
|
||||
let image = renderer.image { _ in
|
||||
text.draw(at: .zero, withAttributes: attributes)
|
||||
}
|
||||
guard
|
||||
let data = image.cgImage?.dataProvider?.data,
|
||||
let bytes = CFDataGetBytePtr(data)
|
||||
else { return false }
|
||||
return containsColoredPixel(Array(UnsafeBufferPointer(start: bytes, count: CFDataGetLength(data))))
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import Testing
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
struct DeviceCensorshipTests {
|
||||
@Test
|
||||
func colorDetectionHandlesColorGrayscaleAndMissingPixels() {
|
||||
#expect(DeviceCensorship.containsColoredPixel([0, 0, 255, 255]))
|
||||
#expect(!DeviceCensorship.containsColoredPixel([64, 64, 64, 255]))
|
||||
#expect(!DeviceCensorship.containsColoredPixel([]))
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,4 @@ nonisolated enum DesktopSettingsKey {
|
||||
UserDefaults.standard.object(forKey: openAtLoginQuietly) as? Bool
|
||||
?? defaultOpenAtLoginQuietly
|
||||
}
|
||||
/// The last approval posture selected in the Assistant composer.
|
||||
static let agentPermissionMode = "assistant.permissionMode"
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import AgentKit
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindRemoteInfrastructure
|
||||
|
||||
nonisolated struct MCPUploadRequest: Sendable {
|
||||
|
||||
@@ -273,7 +273,6 @@ nonisolated enum Metrics {
|
||||
|
||||
enum VaultContent {
|
||||
static let min: CGFloat = 280
|
||||
static let ideal: CGFloat = 680
|
||||
static let splitMinWidth: CGFloat = min + 1 + Metrics.VaultDetail.width
|
||||
}
|
||||
|
||||
|
||||
@@ -163,14 +163,12 @@ struct ConversationView: View {
|
||||
SteeringQueueView(
|
||||
conversation: conversation,
|
||||
isCompact: isCompact,
|
||||
isEmbedded: true,
|
||||
surfaceStyle: interactionSurfaceStyle
|
||||
isEmbedded: true
|
||||
)
|
||||
}
|
||||
}
|
||||
ComposerView(
|
||||
conversation: conversation,
|
||||
surfaceStyle: interactionSurfaceStyle,
|
||||
maxContentWidth: isCompact ? nil : Metrics.Chat.contentMaxWidth,
|
||||
sessionContext: sessionContext,
|
||||
includesTerminalSuggestion: showsTerminalActions,
|
||||
@@ -185,7 +183,7 @@ struct ConversationView: View {
|
||||
private var archivedSurface: some View {
|
||||
if isCompact {
|
||||
archivedSurfaceContent
|
||||
.composerBlock(style: interactionSurfaceStyle)
|
||||
.composerBlock()
|
||||
.padding(.horizontal, Metrics.Space.l)
|
||||
.padding(.top, Metrics.Space.s)
|
||||
.padding(.bottom, Metrics.Space.s)
|
||||
@@ -209,10 +207,6 @@ struct ConversationView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var interactionSurfaceStyle: AssistantInteractionSurfaceStyle {
|
||||
isCompact ? .sidebar : .standard
|
||||
}
|
||||
|
||||
private func chatColumn<Content: View>(
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
@@ -253,7 +247,7 @@ struct ConversationView: View {
|
||||
),
|
||||
alignment: .top
|
||||
)
|
||||
.composerBlock(style: interactionSurfaceStyle)
|
||||
.composerBlock()
|
||||
.padding(.horizontal, Metrics.Space.l)
|
||||
.frame(maxWidth: isCompact ? .infinity : Metrics.Chat.contentMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -928,7 +922,6 @@ struct SteeringQueueView: View {
|
||||
let conversation: ConversationModel
|
||||
var isCompact = false
|
||||
var isEmbedded = false
|
||||
var surfaceStyle: AssistantInteractionSurfaceStyle = .standard
|
||||
|
||||
/// The rows' own height, because a `ScrollView` will not report one — left
|
||||
/// alone it takes every point it is offered, and the strip would stand at
|
||||
@@ -964,7 +957,7 @@ struct SteeringQueueView: View {
|
||||
// as one object with a queue on top rather than as two panels — see
|
||||
// `assistantInteractionSurface`. No hand-drawn stroke: system glass
|
||||
// draws its own edge, and a second one on top of it reads as a seam.
|
||||
.assistantInteractionSurface(style: surfaceStyle)
|
||||
.assistantInteractionSurface()
|
||||
.padding(.horizontal, isEmbedded ? 0 : Metrics.Space.l)
|
||||
.padding(.top, Metrics.Space.s)
|
||||
.accessibilityElement(children: .contain)
|
||||
|
||||
@@ -340,13 +340,6 @@ struct FlowLayout: Layout {
|
||||
/// meant to read as the same object. Two hand-written copies of the same six
|
||||
/// lines drift the first time one radius changes, and the drift shows up as two
|
||||
/// blocks in one column that are almost — but not quite — the same shape.
|
||||
/// Which column the interaction surface is in. It no longer changes what the
|
||||
/// surface is *made of* — both are system glass — only how much room it has.
|
||||
enum AssistantInteractionSurfaceStyle {
|
||||
case standard
|
||||
case sidebar
|
||||
}
|
||||
|
||||
/// The one interactive surface in the Assistant column.
|
||||
///
|
||||
/// Composer, Steering and the archived-conversation notice are the same object
|
||||
@@ -355,7 +348,6 @@ enum AssistantInteractionSurfaceStyle {
|
||||
/// hand-drawn stroke directly above a Composer made of system glass, which read
|
||||
/// as two unrelated panels stacked by accident.
|
||||
private struct AssistantInteractionSurface: ViewModifier {
|
||||
let style: AssistantInteractionSurfaceStyle
|
||||
let isEmphasized: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
@@ -387,12 +379,10 @@ private struct AssistantInteractionGlass: View, Equatable {
|
||||
|
||||
extension View {
|
||||
func assistantInteractionSurface(
|
||||
style: AssistantInteractionSurfaceStyle = .standard,
|
||||
isEmphasized: Bool = false
|
||||
) -> some View {
|
||||
modifier(
|
||||
AssistantInteractionSurface(
|
||||
style: style,
|
||||
isEmphasized: isEmphasized
|
||||
)
|
||||
)
|
||||
@@ -401,10 +391,9 @@ extension View {
|
||||
/// The composer's own name for the shared surface, kept so the call sites
|
||||
/// that mean "this is the composer block" still read that way.
|
||||
func composerBlock(
|
||||
style: AssistantInteractionSurfaceStyle = .standard,
|
||||
isEmphasized: Bool = false
|
||||
) -> some View {
|
||||
assistantInteractionSurface(style: style, isEmphasized: isEmphasized)
|
||||
assistantInteractionSurface(isEmphasized: isEmphasized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,7 +499,6 @@ struct ComposerView: View {
|
||||
|
||||
let conversation: ConversationModel
|
||||
var prompt: LocalizedStringKey = "Ask anything…"
|
||||
var surfaceStyle: AssistantInteractionSurfaceStyle = .standard
|
||||
/// The full Assistant destination shares one centered column with its transcript.
|
||||
/// `nil` keeps compact/sidebar composers fluid.
|
||||
var maxContentWidth: CGFloat? = nil
|
||||
@@ -615,10 +603,7 @@ struct ComposerView: View {
|
||||
.frame(maxWidth: .infinity, minHeight: 32, maxHeight: 32)
|
||||
.animation(Motion.panel(reduceMotion: reduceMotion), value: showsTranscriptionControls)
|
||||
}
|
||||
.composerBlock(
|
||||
style: surfaceStyle,
|
||||
isEmphasized: isFocused
|
||||
)
|
||||
.composerBlock(isEmphasized: isFocused)
|
||||
// Anchored to the whole surface rather than the TextEditor. The editor
|
||||
// begins inside the surface padding (and can have Context chips above
|
||||
// it), which let the palette overlap the Chat frame.
|
||||
@@ -1052,10 +1037,6 @@ struct ComposerView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var slashCommands: [ChatSlashCommand] {
|
||||
slashRegistry.commands(in: slashContext)
|
||||
}
|
||||
|
||||
private var slashOptions: (ChatSlashCommand) -> [ChatSlashCommand.Option] {
|
||||
{ slashRegistry.options(for: $0, in: slashContext) }
|
||||
}
|
||||
@@ -1095,7 +1076,6 @@ struct ComposerView: View {
|
||||
completion: completion,
|
||||
selection: min(slashSelection, completion.rows.count - 1),
|
||||
scrollRequest: slashScrollRequest,
|
||||
surfaceStyle: surfaceStyle,
|
||||
contextShare: contextShare,
|
||||
trailingText: slashTrailingText,
|
||||
onHover: { slashSelection = $0 },
|
||||
|
||||
@@ -1,172 +1,12 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import MarkdownParser
|
||||
import MarkdownView
|
||||
import SwiftUI
|
||||
import TermindPresentation
|
||||
|
||||
/// The Markdown entry point used by the transcript.
|
||||
///
|
||||
/// MarkdownView owns parsing, block layout, tables, syntax highlighting and
|
||||
/// text selection. Termind keeps a thin policy layer around it for the two
|
||||
/// things a general renderer must not decide for this app: which links are safe
|
||||
/// to open and which assistant-authored shell fences may reach a terminal.
|
||||
struct TermindMarkdownView: View {
|
||||
enum Tone: Hashable {
|
||||
case primary
|
||||
case secondary
|
||||
}
|
||||
|
||||
let text: String
|
||||
var isStreaming = false
|
||||
var isAssistant = true
|
||||
var tone: Tone = .primary
|
||||
|
||||
@Environment(\.markdownFillsWidth) private var fillsWidth
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@StateObject private var model: RenderingModel
|
||||
|
||||
init(
|
||||
text: String,
|
||||
isStreaming: Bool = false,
|
||||
isAssistant: Bool = true,
|
||||
isCompact _: Bool = false,
|
||||
tone: Tone = .primary
|
||||
) {
|
||||
self.text = text
|
||||
self.isStreaming = isStreaming
|
||||
self.isAssistant = isAssistant
|
||||
self.tone = tone
|
||||
|
||||
let theme = TermindMarkdownTheme.make(tone: tone)
|
||||
_model = StateObject(
|
||||
wrappedValue: RenderingModel(
|
||||
source: text,
|
||||
allowsShellActions: isAssistant,
|
||||
theme: theme
|
||||
))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Metrics.Chat.blockSpacing) {
|
||||
ForEach(model.rendered.segments) { segment in
|
||||
switch segment.content {
|
||||
case .markdown(let content):
|
||||
TermindMarkdownNativeView(
|
||||
content: content,
|
||||
theme: model.rendered.theme,
|
||||
fillsWidth: fillsWidth
|
||||
)
|
||||
case .shell(let language, let command):
|
||||
AssistantShellCodeBlockView(
|
||||
language: language,
|
||||
command: command
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.markdownWidth(fills: fillsWidth)
|
||||
.onChange(
|
||||
of: UpdateID(
|
||||
text: text,
|
||||
isStreaming: isStreaming,
|
||||
isAssistant: isAssistant,
|
||||
tone: tone,
|
||||
colorScheme: colorScheme
|
||||
),
|
||||
initial: true
|
||||
) { _, _ in
|
||||
refreshIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// MarkdownView is designed for streaming, but splitting actionable shell
|
||||
/// blocks still requires a root parse. Upstream's balanced emitter already
|
||||
/// controls the presentation cadence, so every emitted delta is rendered
|
||||
/// immediately instead of being throttled again at the view layer.
|
||||
private func refreshIfNeeded() {
|
||||
guard
|
||||
model.source != text
|
||||
|| model.rendered.allowsShellActions != isAssistant
|
||||
|| model.rendered.themeTone != tone
|
||||
|| model.rendered.themeColorScheme != colorScheme
|
||||
else { return }
|
||||
refresh()
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
model.refresh(
|
||||
source: text,
|
||||
allowsShellActions: isAssistant,
|
||||
theme: TermindMarkdownTheme.make(tone: tone, colorScheme: colorScheme)
|
||||
)
|
||||
}
|
||||
|
||||
private struct UpdateID: Hashable {
|
||||
let text: String
|
||||
let isStreaming: Bool
|
||||
let isAssistant: Bool
|
||||
let tone: Tone
|
||||
let colorScheme: ColorScheme
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class RenderingModel: ObservableObject {
|
||||
@Published private(set) var rendered: TermindMarkdownRenderedDocument
|
||||
private(set) var source: String
|
||||
|
||||
init(
|
||||
source: String,
|
||||
allowsShellActions: Bool,
|
||||
theme: TermindMarkdownTheme.Value
|
||||
) {
|
||||
self.source = source
|
||||
rendered = TermindMarkdownRenderedDocument(
|
||||
source: source,
|
||||
allowsShellActions: allowsShellActions,
|
||||
theme: theme
|
||||
)
|
||||
}
|
||||
|
||||
func refresh(
|
||||
source: String,
|
||||
allowsShellActions: Bool,
|
||||
theme: TermindMarkdownTheme.Value
|
||||
) {
|
||||
rendered = TermindMarkdownRenderedDocument(
|
||||
source: source,
|
||||
allowsShellActions: allowsShellActions,
|
||||
theme: theme
|
||||
)
|
||||
self.source = source
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MarkdownFillsWidthKey: EnvironmentKey {
|
||||
nonisolated static let defaultValue = true
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Assistant prose and plans fill their reading column. User messages opt
|
||||
/// out so a short question keeps a short bubble.
|
||||
var markdownFillsWidth: Bool {
|
||||
get { self[MarkdownFillsWidthKey.self] }
|
||||
set { self[MarkdownFillsWidthKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func markdownWidth(fills: Bool, alignment: Alignment = .leading) -> some View {
|
||||
frame(maxWidth: fills ? .infinity : nil, alignment: alignment)
|
||||
}
|
||||
}
|
||||
|
||||
/// Kept as the transcript-wide type scale for non-Markdown labels and the
|
||||
/// Termind-owned shell card. MarkdownView receives matching AppKit fonts below.
|
||||
enum MarkdownTypography {
|
||||
static let body = Font.body
|
||||
static let monospaced = Font.system(.body, design: .monospaced)
|
||||
enum TermindMarkdownTone: Hashable {
|
||||
case primary
|
||||
case secondary
|
||||
}
|
||||
|
||||
// MARK: - Parsing policy
|
||||
@@ -324,9 +164,6 @@ struct TermindMarkdownRenderedDocument {
|
||||
|
||||
let segments: [Segment]
|
||||
let theme: MarkdownTheme
|
||||
let allowsShellActions: Bool
|
||||
let themeTone: TermindMarkdownView.Tone
|
||||
let themeColorScheme: ColorScheme?
|
||||
|
||||
init(source: String, allowsShellActions: Bool, theme: TermindMarkdownTheme.Value) {
|
||||
let parserResult = MarkdownParser().parse(MarkdownSoftBreaks.hardened(source))
|
||||
@@ -355,9 +192,6 @@ struct TermindMarkdownRenderedDocument {
|
||||
}
|
||||
}
|
||||
self.theme = theme.theme
|
||||
self.allowsShellActions = allowsShellActions
|
||||
themeTone = theme.tone
|
||||
themeColorScheme = theme.colorScheme
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,12 +199,11 @@ struct TermindMarkdownRenderedDocument {
|
||||
enum TermindMarkdownTheme {
|
||||
struct Value {
|
||||
let theme: MarkdownTheme
|
||||
let tone: TermindMarkdownView.Tone
|
||||
let colorScheme: ColorScheme?
|
||||
}
|
||||
|
||||
static func make(
|
||||
tone: TermindMarkdownView.Tone,
|
||||
tone: TermindMarkdownTone,
|
||||
colorScheme: ColorScheme? = nil
|
||||
) -> Value {
|
||||
var theme = MarkdownTheme()
|
||||
@@ -412,7 +245,7 @@ enum TermindMarkdownTheme {
|
||||
theme.table.stripeCellBackgroundColor = resolved(
|
||||
.quaternaryLabelColor, colorScheme: colorScheme
|
||||
).withAlphaComponent(0.06)
|
||||
return Value(theme: theme, tone: tone, colorScheme: colorScheme)
|
||||
return Value(theme: theme, colorScheme: colorScheme)
|
||||
}
|
||||
|
||||
private static func resolved(_ color: NSColor, colorScheme: ColorScheme?) -> NSColor {
|
||||
@@ -429,107 +262,3 @@ enum TermindMarkdownTheme {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AppKit bridge
|
||||
|
||||
private struct TermindMarkdownNativeView: NSViewRepresentable {
|
||||
let content: MarkdownContent
|
||||
let theme: MarkdownTheme
|
||||
let fillsWidth: Bool
|
||||
|
||||
func makeNSView(context: Context) -> MarkdownTextView {
|
||||
let view = MarkdownTextView()
|
||||
view.setContentHuggingPriority(.required, for: .vertical)
|
||||
view.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
view.setContentHuggingPriority(fillsWidth ? .defaultLow : .required, for: .horizontal)
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
view.linkHandler = { payload, _, _ in
|
||||
TermindMarkdownLinkOpener.open(payload)
|
||||
}
|
||||
context.coordinator.apply(content: content, theme: theme, to: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: MarkdownTextView, context: Context) {
|
||||
view.setContentHuggingPriority(fillsWidth ? .defaultLow : .required, for: .horizontal)
|
||||
context.coordinator.apply(content: content, theme: theme, to: view)
|
||||
}
|
||||
|
||||
func sizeThatFits(
|
||||
_ proposal: ProposedViewSize,
|
||||
nsView view: MarkdownTextView,
|
||||
context: Context
|
||||
) -> CGSize? {
|
||||
context.coordinator.sizeThatFits(
|
||||
proposal,
|
||||
view: view,
|
||||
fillsWidth: fillsWidth
|
||||
)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator {
|
||||
private var lastContent: MarkdownContent?
|
||||
private var lastTheme: MarkdownTheme?
|
||||
private var concreteWidth: CGFloat = 0
|
||||
|
||||
func apply(content: MarkdownContent, theme: MarkdownTheme, to view: MarkdownTextView) {
|
||||
guard lastContent !== content || lastTheme != theme else { return }
|
||||
view.setContentImmediately(content, theme: theme)
|
||||
view.invalidateIntrinsicContentSize()
|
||||
lastContent = content
|
||||
lastTheme = theme
|
||||
}
|
||||
|
||||
func sizeThatFits(
|
||||
_ proposal: ProposedViewSize,
|
||||
view: MarkdownTextView,
|
||||
fillsWidth: Bool
|
||||
) -> CGSize? {
|
||||
if proposal.width == 0 { return .zero }
|
||||
|
||||
let availableWidth: CGFloat
|
||||
if let width = proposal.width, width.isFinite, width > 0 {
|
||||
availableWidth = width
|
||||
concreteWidth = width
|
||||
} else if concreteWidth > 0 {
|
||||
availableWidth = concreteWidth
|
||||
} else if view.bounds.width > 0 {
|
||||
availableWidth = view.bounds.width
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let bounded = view.boundingSize(for: availableWidth)
|
||||
let width =
|
||||
fillsWidth
|
||||
? availableWidth
|
||||
: min(availableWidth, max(1, ceil(bounded.width)))
|
||||
let height = ceil(view.boundingSize(for: width).height)
|
||||
return CGSize(width: width, height: height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum TermindMarkdownLinkOpener {
|
||||
static func open(_ payload: LinkPayload) {
|
||||
let url: URL?
|
||||
switch payload {
|
||||
case .url(let value):
|
||||
url = value
|
||||
case .string(let value):
|
||||
url = URL(string: value)
|
||||
}
|
||||
guard let url,
|
||||
TermindMarkdownParser.allowedLinkSchemes.contains(
|
||||
url.scheme?.lowercased() ?? ""
|
||||
)
|
||||
else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
@@ -1,129 +1,4 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
|
||||
/// The one fenced block Termind owns instead of MarkdownView: a shell command
|
||||
/// authored by the assistant and therefore eligible for local risk assessment.
|
||||
struct AssistantShellCodeBlockView: View {
|
||||
let language: String
|
||||
let command: String
|
||||
|
||||
var body: some View {
|
||||
ShellCodeBlockView(language: language, command: command)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shell
|
||||
|
||||
private struct ShellCodeBlockView: View {
|
||||
let language: String
|
||||
let command: String
|
||||
|
||||
@Environment(WindowModel.self) private var window
|
||||
@State private var isConfirming = false
|
||||
|
||||
private var assessment: RiskAssessment { CommandRiskClassifier.assess(command) }
|
||||
|
||||
/// Whether this fence may be delivered to a prompt at all.
|
||||
///
|
||||
/// `SSHSessionModel.insert` writes raw bytes with no bracketed-paste
|
||||
/// wrapper, so an embedded newline reaches the PTY as Return. "Insert" on a
|
||||
/// three-line script would execute the first two lines while presenting
|
||||
/// itself as a preview — so multi-line fences get the badge and a copy
|
||||
/// button and nothing that touches the session.
|
||||
private var isSingleLine: Bool { !command.contains("\n") }
|
||||
|
||||
/// The shell this card would reach.
|
||||
///
|
||||
/// `selectedWorkspace` is `nil` whenever the pinned Assistant destination is showing, and
|
||||
/// that is exactly where the full-width transcript lives — so the buttons
|
||||
/// disable there instead of looking live and doing nothing.
|
||||
private var target: SSHSessionModel? {
|
||||
guard let workspace = window.selectedWorkspace else { return nil }
|
||||
return workspace.focusedPane.sshSession ?? workspace.broadcastTargets.first
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
||||
HStack(spacing: Metrics.Space.xs) {
|
||||
Text(language)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: Metrics.Space.s)
|
||||
RiskBadge(assessment: assessment)
|
||||
}
|
||||
|
||||
Text(command)
|
||||
.font(MarkdownTypography.monospaced)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
actions
|
||||
}
|
||||
.padding(Metrics.Chat.codeInset)
|
||||
.background(
|
||||
Color(nsColor: .controlBackgroundColor),
|
||||
in: .rect(cornerRadius: Metrics.Radius.large)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Metrics.Radius.large)
|
||||
.strokeBorder(
|
||||
assessment.risk == .destructive
|
||||
? TermindColor.danger.opacity(0.4) : Color.secondary.opacity(0.2)
|
||||
)
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("Run a destructive command?"),
|
||||
isPresented: $isConfirming,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Run \(command)", role: .destructive) { deliver(submit: true) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text(assessment.reasons.joined(separator: "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actions: some View {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
if isSingleLine {
|
||||
Button("Insert") { deliver(submit: false) }
|
||||
.disabled(target == nil)
|
||||
Button("Run") { run() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(
|
||||
assessment.risk == .destructive
|
||||
? TermindColor.danger : TermindColor.accent
|
||||
)
|
||||
.disabled(target == nil)
|
||||
} else {
|
||||
Text("Several commands — copy and review before running.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: Metrics.Space.s)
|
||||
CopyButton(text: command)
|
||||
}
|
||||
.controlSize(.small)
|
||||
.help(Text(target == nil ? String(localized: "No shell in the foreground") : ""))
|
||||
}
|
||||
|
||||
private func run() {
|
||||
if assessment.requiresSecondConfirmation {
|
||||
isConfirming = true
|
||||
} else {
|
||||
deliver(submit: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Through `Workspace.deliver`, so a card obeys the same broadcast rule as
|
||||
/// every other programmatic route to a prompt. `submit: false` writes
|
||||
/// without the newline; the user's own Return is what runs it.
|
||||
private func deliver(submit: Bool) {
|
||||
guard let workspace = window.selectedWorkspace, let origin = target else { return }
|
||||
workspace.deliver(command, from: origin, submit: submit)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Copy
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ struct SlashCommandMenuView: View {
|
||||
/// Advanced only by keyboard navigation. Hover still changes the highlight,
|
||||
/// but must not recenter a list underneath a stationary pointer.
|
||||
let scrollRequest: Int
|
||||
var surfaceStyle: AssistantInteractionSurfaceStyle = .standard
|
||||
var contextShare: Double?
|
||||
/// The value a row currently holds — the resolved effort beside
|
||||
/// `/reasoning`. Returning `nil` leaves the trailing column empty.
|
||||
@@ -48,7 +47,7 @@ struct SlashCommandMenuView: View {
|
||||
// what stops a short list from rubber-banding when the pointer crosses
|
||||
// it while longer command and option lists remain scrollable.
|
||||
.frame(height: listHeight)
|
||||
.composerBlock(style: surfaceStyle)
|
||||
.composerBlock()
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(Text("Commands"))
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ final class TranscriptMarkdownCache {
|
||||
init(
|
||||
source: String,
|
||||
allowsShellActions: Bool,
|
||||
tone: TermindMarkdownView.Tone,
|
||||
tone: TermindMarkdownTone,
|
||||
colorScheme: ColorScheme
|
||||
) {
|
||||
self.source = source
|
||||
@@ -34,7 +34,7 @@ final class TranscriptMarkdownCache {
|
||||
let slot: String
|
||||
let source: String
|
||||
let shell: Bool
|
||||
let tone: TermindMarkdownView.Tone
|
||||
let tone: TermindMarkdownTone
|
||||
let colorScheme: ColorScheme
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ final class TranscriptMarkdownCache {
|
||||
slot: String,
|
||||
source: String,
|
||||
shell: Bool,
|
||||
tone: TermindMarkdownView.Tone = .primary,
|
||||
tone: TermindMarkdownTone = .primary,
|
||||
colorScheme: ColorScheme
|
||||
) -> Package {
|
||||
let key = Key(
|
||||
@@ -1234,7 +1234,7 @@ private enum NativeTranscriptFactory {
|
||||
source: String,
|
||||
slot: String,
|
||||
shell: Bool,
|
||||
tone: TermindMarkdownView.Tone = .primary,
|
||||
tone: TermindMarkdownTone = .primary,
|
||||
fillsWidth: Bool = true,
|
||||
horizontalInset: CGFloat = 0,
|
||||
context: NativeTranscriptContext
|
||||
@@ -1632,15 +1632,6 @@ private enum NativeTranscriptFactory {
|
||||
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func nativeRiskColor(_ risk: CommandRisk) -> NSColor {
|
||||
switch risk {
|
||||
case .safe: .systemGreen
|
||||
case .caution: .systemOrange
|
||||
case .destructive: .systemRed
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func nativeHierarchicalColor(_ color: NSColor, opacity: CGFloat) -> NSColor {
|
||||
NSColor(name: nil) { appearance in
|
||||
|
||||
@@ -212,7 +212,6 @@ struct AssistantPanelView: View {
|
||||
ComposerView(
|
||||
conversation: window.sidebar.assistantDraft,
|
||||
prompt: "Ask anything…",
|
||||
surfaceStyle: .sidebar,
|
||||
sessionContext: window.sidebar.sessionContext,
|
||||
includesTerminalSuggestion: true,
|
||||
onSend: {
|
||||
@@ -695,7 +694,7 @@ nonisolated enum AssistantConversationDropResolver {
|
||||
}
|
||||
return .init(
|
||||
section: section,
|
||||
beforeID: nextConversation(after: index, in: section, items: items)
|
||||
beforeID: nextConversation(after: index, items: items)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -712,12 +711,11 @@ nonisolated enum AssistantConversationDropResolver {
|
||||
guard let sectionIndex = items.firstIndex(where: { $0.section == section }) else {
|
||||
return nil
|
||||
}
|
||||
return nextConversation(after: sectionIndex, in: section, items: items)
|
||||
return nextConversation(after: sectionIndex, items: items)
|
||||
}
|
||||
|
||||
private static func nextConversation(
|
||||
after index: Int,
|
||||
in section: AssistantConversationListItem.Section,
|
||||
items: [AssistantConversationListItem]
|
||||
) -> UUID? {
|
||||
guard index + 1 < items.count else { return nil }
|
||||
@@ -878,7 +876,7 @@ private struct AssistantConversationNativeList: NSViewRepresentable {
|
||||
.environment(window)
|
||||
.environment(\.locale, locale)
|
||||
.environment(\.colorScheme, colorScheme)
|
||||
row.configure(id: item.id, rootView: AnyView(content))
|
||||
row.configure(rootView: AnyView(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1064,7 +1062,6 @@ final class AssistantConversationListContainer: NSView {
|
||||
@MainActor
|
||||
private final class AssistantConversationHostingRow: ListRowView {
|
||||
private let hostingView = NSHostingView(rootView: AnyView(EmptyView()))
|
||||
private var representedID: AssistantConversationListItem.ID?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -1094,13 +1091,11 @@ private final class AssistantConversationHostingRow: ListRowView {
|
||||
empty()
|
||||
}
|
||||
|
||||
func configure(id: AssistantConversationListItem.ID, rootView: AnyView) {
|
||||
representedID = id
|
||||
func configure(rootView: AnyView) {
|
||||
hostingView.rootView = rootView
|
||||
}
|
||||
|
||||
private func empty() {
|
||||
representedID = nil
|
||||
hostingView.rootView = AnyView(EmptyView())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ struct KnownHostsPageView: ManagedCardPage {
|
||||
typealias Item = KnownHostEntry
|
||||
@Environment(AppModel.self) private var app
|
||||
@Environment(WindowModel.self) private var window
|
||||
let state: KnownHostsPageState
|
||||
@Bindable var state: KnownHostsPageState
|
||||
|
||||
var managedSections: [ManagedCardSection<String, KnownHostEntry>] {
|
||||
[
|
||||
@@ -74,9 +74,11 @@ struct KnownHostsPageView: ManagedCardPage {
|
||||
}
|
||||
}
|
||||
func managedNoResultsState() -> some View { ContentUnavailableView.search(text: state.query) }
|
||||
var managedPresentation: KnownHostsPresentation {
|
||||
KnownHostsPresentation(
|
||||
state: state, ids: app.knownHosts.entries.map(\.id),
|
||||
var managedPresentation: ManagedCardDeletionPresentation<UUID> {
|
||||
ManagedCardDeletionPresentation(
|
||||
deleting: $state.deleting, error: $state.error, ids: app.knownHosts.entries.map(\.id),
|
||||
confirmationTitle: "Delete Host Keys?", errorTitle: "Known Hosts Operation Failed",
|
||||
explanation: Text("The next connection will ask you to verify the server key again."),
|
||||
prune: { window.pruneVaultSelections(in: app.hostLibrary) },
|
||||
delete: {
|
||||
if !app.knownHosts.delete(state.deleting) { state.error = app.knownHosts.storageError }
|
||||
@@ -121,34 +123,6 @@ private struct KnownHostCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct KnownHostsPresentation: ManagedCardPresentationModifier {
|
||||
@Bindable var state: KnownHostsPageState
|
||||
let ids: [UUID]
|
||||
let prune: () -> Void
|
||||
let delete: () -> Void
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear(perform: prune)
|
||||
.onChange(of: ids) { prune() }
|
||||
.confirmationDialog(
|
||||
"Delete Host Keys?",
|
||||
isPresented: Binding(get: { !state.deleting.isEmpty }, set: { if !$0 { state.deleting = [] } })
|
||||
) {
|
||||
Button("Delete", role: .destructive, action: delete)
|
||||
} message: {
|
||||
Text("The next connection will ask you to verify the server key again.")
|
||||
}
|
||||
.alert(
|
||||
"Known Hosts Operation Failed",
|
||||
isPresented: Binding(get: { state.error != nil }, set: { if !$0 { state.error = nil } })
|
||||
) {
|
||||
Button("OK") { state.error = nil }
|
||||
} message: {
|
||||
Text(state.error ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KnownHostEditorView: View {
|
||||
@Environment(AppModel.self) private var app
|
||||
@Environment(WindowModel.self) private var window
|
||||
|
||||
@@ -4,7 +4,6 @@ import TermindApplication
|
||||
/// Connection failures stay actionable even though healthy metrics need no status footer.
|
||||
struct MetricsConnectionNotice: View {
|
||||
let server: ServerMetric
|
||||
let model: ServerMetricsModel
|
||||
|
||||
var body: some View {
|
||||
if !server.status.isEmpty, server.status != String(localized: "Live") {
|
||||
|
||||
@@ -4,10 +4,9 @@ import TermindPresentation
|
||||
|
||||
struct MetricsServerDetails: View {
|
||||
let server: ServerMetric
|
||||
let model: ServerMetricsModel
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
MetricsConnectionNotice(server: server, model: model)
|
||||
MetricsConnectionNotice(server: server)
|
||||
MetricsSystemPanels(server: server)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ struct MetricsDetailView: View {
|
||||
|
||||
MetricsDetailLayout(split: split, page: window.metricsDetailPage) {
|
||||
ScrollView {
|
||||
MetricsServerDetails(server: server, model: model)
|
||||
MetricsServerDetails(server: server)
|
||||
.padding(1)
|
||||
}
|
||||
.metricsPlainScrollEdges()
|
||||
|
||||
@@ -16,7 +16,7 @@ struct PortForwardingPageView: ManagedCardPage {
|
||||
typealias Item = PortForwardingRule
|
||||
@Environment(AppModel.self) private var app
|
||||
@Environment(WindowModel.self) private var window
|
||||
let state: PortForwardingPageState
|
||||
@Bindable var state: PortForwardingPageState
|
||||
|
||||
var managedSections: [ManagedCardSection<String, PortForwardingRule>] {
|
||||
[
|
||||
@@ -87,9 +87,10 @@ struct PortForwardingPageView: ManagedCardPage {
|
||||
}
|
||||
}
|
||||
func managedNoResultsState() -> some View { ContentUnavailableView.search(text: state.query) }
|
||||
var managedPresentation: PortForwardingPagePresentation {
|
||||
PortForwardingPagePresentation(
|
||||
state: state, ids: app.forwardings.rules.map(\.id),
|
||||
var managedPresentation: ManagedCardDeletionPresentation<UUID> {
|
||||
ManagedCardDeletionPresentation(
|
||||
deleting: $state.deleting, error: $state.error, ids: app.forwardings.rules.map(\.id),
|
||||
confirmationTitle: "Delete Forwarding Rules?", errorTitle: "Operation Failed",
|
||||
prune: { window.pruneVaultSelections(in: app.hostLibrary) },
|
||||
delete: {
|
||||
do { try app.forwardings.delete(state.deleting) } catch { state.error = error.localizedDescription }
|
||||
@@ -132,10 +133,9 @@ struct PortForwardingPageView: ManagedCardPage {
|
||||
}
|
||||
}
|
||||
private func canStart(_ rule: Item) -> Bool {
|
||||
PortForwardingEditorPolicy(
|
||||
rule: nil, savedRule: rule, savedHost: app.hostLibrary.host(rule.hostID),
|
||||
isRuntimeActive: app.forwarding.state(for: rule.id).isActive
|
||||
).canStart
|
||||
PortForwardingEditorPolicy.canStart(
|
||||
rule.id, savedRule: rule, savedHost: app.hostLibrary.host(rule.hostID),
|
||||
isRuntimeActive: app.forwarding.state(for: rule.id).isActive)
|
||||
}
|
||||
private func open(_ rule: Item) { window.openVaultWork(.forwardingEditor(rule.id), on: .portForwarding) }
|
||||
private func start(_ rule: Item) {
|
||||
@@ -153,30 +153,6 @@ struct PortForwardingPageView: ManagedCardPage {
|
||||
}
|
||||
}
|
||||
|
||||
struct PortForwardingPagePresentation: ManagedCardPresentationModifier {
|
||||
@Bindable var state: PortForwardingPageState
|
||||
let ids: [UUID]
|
||||
let prune: () -> Void
|
||||
let delete: () -> Void
|
||||
func body(content: Content) -> some View {
|
||||
content.onAppear(perform: prune).onChange(of: ids) { prune() }
|
||||
.confirmationDialog(
|
||||
"Delete Forwarding Rules?",
|
||||
isPresented: Binding(get: { !state.deleting.isEmpty }, set: { if !$0 { state.deleting = [] } })
|
||||
) {
|
||||
Button("Delete", role: .destructive, action: delete)
|
||||
}
|
||||
.alert(
|
||||
"Operation Failed",
|
||||
isPresented: Binding(get: { state.error != nil }, set: { if !$0 { state.error = nil } })
|
||||
) {
|
||||
Button("OK") { state.error = nil }
|
||||
} message: {
|
||||
Text(state.error ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PortForwardingDetailView: View {
|
||||
let ruleID: UUID?
|
||||
@Environment(AppModel.self) private var app
|
||||
|
||||
@@ -313,7 +313,7 @@ struct SFTPBrowserView: View {
|
||||
)
|
||||
},
|
||||
continueHostKey: {
|
||||
sftpSession.continueWithTemporaryHostKey($0, using: app.transfers)
|
||||
sftpSession.continueWithTemporaryHostKey($0)
|
||||
},
|
||||
trustHostKey: { sftpSession.trustAndConnect($0, using: app.transfers) },
|
||||
denyHostKey: {
|
||||
|
||||
@@ -568,9 +568,7 @@ final class SFTPSessionModel: Identifiable {
|
||||
connection.trustHostKey(observation, using: store)
|
||||
}
|
||||
|
||||
func continueWithTemporaryHostKey(
|
||||
_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore
|
||||
) {
|
||||
func continueWithTemporaryHostKey(_ observation: SSHHostKeyObservation) {
|
||||
_ = connection.useTemporaryHostKey(observation)
|
||||
}
|
||||
|
||||
|
||||
@@ -218,8 +218,6 @@ struct SnippetsPageView: ManagedCardPage {
|
||||
state: state,
|
||||
actions: SnippetsPagePresentation.Actions(
|
||||
store: app.snippets,
|
||||
saveSnippet: saveSnippet,
|
||||
savePackage: savePackage,
|
||||
deletePackage: deletePackage,
|
||||
deleteSnippets: deleteSnippets,
|
||||
performExecution: performExecution,
|
||||
@@ -254,10 +252,6 @@ struct SnippetsPageView: ManagedCardPage {
|
||||
saveSnippet(snippet.duplicated())
|
||||
}
|
||||
|
||||
private func savePackage(_ package: SnippetPackage) {
|
||||
app.snippets.upsert(package)
|
||||
}
|
||||
|
||||
private func deletePackage(_ package: SnippetPackage, keepSnippets: Bool) {
|
||||
let deletedPackageIDs = Set(
|
||||
[package.id] + app.snippets.descendantSnippetPackages(of: package.id).map(\.id)
|
||||
@@ -346,8 +340,6 @@ struct SnippetsPageView: ManagedCardPage {
|
||||
struct SnippetsPagePresentation: ManagedCardPresentationModifier {
|
||||
struct Actions {
|
||||
let store: SnippetLibraryModel
|
||||
let saveSnippet: (Snippet) -> Void
|
||||
let savePackage: (SnippetPackage) -> Void
|
||||
let deletePackage: (SnippetPackage, Bool) -> Void
|
||||
let deleteSnippets: ([Snippet]) -> Void
|
||||
let performExecution: ([Snippet]) -> Void
|
||||
|
||||
@@ -738,9 +738,7 @@ final class SSHSessionModel: Identifiable {
|
||||
connection.trustHostKey(observation, using: store)
|
||||
}
|
||||
|
||||
func continueWithTemporaryHostKey(
|
||||
_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore
|
||||
) {
|
||||
func continueWithTemporaryHostKey(_ observation: SSHHostKeyObservation) {
|
||||
_ = connection.useTemporaryHostKey(observation)
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ struct TerminalPlaceholderView: View {
|
||||
)
|
||||
},
|
||||
continueHostKey: {
|
||||
sshSession.continueWithTemporaryHostKey($0, using: app.hostLibrary)
|
||||
sshSession.continueWithTemporaryHostKey($0)
|
||||
},
|
||||
trustHostKey: { sshSession.trustAndConnect($0, using: app.hostLibrary) },
|
||||
denyHostKey: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import TermindRemoteInfrastructure
|
||||
|
||||
import struct TermindDomain.Host
|
||||
@@ -511,7 +510,7 @@ final class WindowModel {
|
||||
workspaces.flatMap { workspace in
|
||||
workspace.panes
|
||||
.filter { $0.kind == kind }
|
||||
.map { PaneRef(workspace: workspace, pane: $0) }
|
||||
.map(PaneRef.init(pane:))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,7 +535,7 @@ final class WindowModel {
|
||||
pane.kind == .sshSession || pane.kind == .sftpSession
|
||||
|| pane.kind == .serialSession
|
||||
else { return nil }
|
||||
return PaneRef(workspace: workspace, pane: pane)
|
||||
return PaneRef(pane: pane)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1483,7 +1482,6 @@ nonisolated enum DragPayload: Hashable, Sendable {
|
||||
/// to know which workspace owns it, and a reference cycle for the sake of the sidebar
|
||||
/// would be paying in the model for something the view can compute.
|
||||
struct PaneRef: Identifiable {
|
||||
let workspace: Workspace
|
||||
let pane: Pane
|
||||
|
||||
var id: Pane.ID { pane.id }
|
||||
|
||||
@@ -22,16 +22,6 @@ enum AppShortcutKey: Hashable, Sendable, ExpressibleByExtendedGraphemeClusterLit
|
||||
}
|
||||
}
|
||||
|
||||
var appKitKeyEquivalent: String {
|
||||
switch self {
|
||||
case .character(let character): String(character)
|
||||
case .tab: "\t"
|
||||
case .escape: "\u{1B}"
|
||||
case .f3: String(Character(UnicodeScalar(NSF3FunctionKey)!))
|
||||
case .rightArrow: String(Character(UnicodeScalar(NSRightArrowFunctionKey)!))
|
||||
case .downArrow: String(Character(UnicodeScalar(NSDownArrowFunctionKey)!))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppShortcutRegistration: Hashable, Sendable {
|
||||
@@ -121,16 +111,3 @@ extension View {
|
||||
return keyboardShortcut(key.keyEquivalent, modifiers: modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSMenuItem {
|
||||
func setTermindShortcut(
|
||||
_ key: AppShortcutKey,
|
||||
modifiers: EventModifiers = .command,
|
||||
terminalRouting: AppShortcutRegistry.TerminalRouting = .application
|
||||
) {
|
||||
let shortcut = AppShortcutRegistration(key, modifiers: modifiers)
|
||||
AppShortcutRegistry.shared.register(shortcut, terminalRouting: terminalRouting)
|
||||
keyEquivalent = key.appKitKeyEquivalent
|
||||
keyEquivalentModifierMask = shortcut.appKitModifiers
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
|
||||
/// Process-owned scene requests; live workspaces stay in their source until the
|
||||
/// destination has a native window. Scene restoration never replays a move.
|
||||
|
||||
@@ -15,13 +15,6 @@ final class MetricsMenuBarController: NSObject, NSPopoverDelegate {
|
||||
|
||||
init(defaults: UserDefaults = .standard) { self.defaults = defaults }
|
||||
|
||||
var isVisible: Bool { item != nil }
|
||||
var isPopoverShown: Bool { popover.isShown }
|
||||
var hasNeutralInitialFocus: Bool {
|
||||
guard let window = popover.contentViewController?.view.window else { return false }
|
||||
return window.firstResponder == nil || window.firstResponder === window
|
||||
}
|
||||
|
||||
func start(_ model: ServerMetricsModel) {
|
||||
self.model = model
|
||||
if observer == nil {
|
||||
|
||||
@@ -67,6 +67,39 @@ struct ManagedCardItemContext<Item: Identifiable> {
|
||||
/// the inherited body after the shared page has been rendered.
|
||||
protocol ManagedCardPresentationModifier: ViewModifier {}
|
||||
|
||||
struct ManagedCardDeletionPresentation<ID: Hashable>: ManagedCardPresentationModifier {
|
||||
@Binding var deleting: Set<ID>
|
||||
@Binding var error: String?
|
||||
let ids: [ID]
|
||||
let confirmationTitle: LocalizedStringKey
|
||||
let errorTitle: LocalizedStringKey
|
||||
var explanation: Text? = nil
|
||||
let prune: () -> Void
|
||||
let delete: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear(perform: prune)
|
||||
.onChange(of: ids) { prune() }
|
||||
.confirmationDialog(
|
||||
confirmationTitle,
|
||||
isPresented: Binding(get: { !deleting.isEmpty }, set: { if !$0 { deleting = [] } })
|
||||
) {
|
||||
Button("Delete", role: .destructive, action: delete)
|
||||
} message: {
|
||||
if let explanation { explanation }
|
||||
}
|
||||
.alert(
|
||||
errorTitle,
|
||||
isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })
|
||||
) {
|
||||
Button("OK") { error = nil }
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI's protocol-oriented equivalent of a page base class. Conformers
|
||||
/// provide data and business views; this protocol extension owns the body.
|
||||
protocol ManagedCardPage: View {
|
||||
|
||||
@@ -2,7 +2,6 @@ import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import TermindPersistence
|
||||
import TermindPresentation
|
||||
import TermindRemoteInfrastructure
|
||||
import Testing
|
||||
@@ -13,66 +12,6 @@ import struct TermindDomain.Host
|
||||
|
||||
@Suite @MainActor
|
||||
struct PortForwardingEditorTests {
|
||||
@Test func savedConfigurationIsIndependentFromTheCurrentDraft() {
|
||||
let host = Host(name: "Server", host: "server.invalid")
|
||||
let rule = PortForwardingRule(hostID: host.id)
|
||||
let library = HostLibraryModel(hosts: [host])
|
||||
func state(_ candidate: PortForwardingRule?, _ candidateHost: Host, saved: Bool = true)
|
||||
-> PortForwardingEditorPolicy
|
||||
{
|
||||
PortForwardingEditorPolicy(
|
||||
rule: candidate, savedRule: saved ? rule : nil,
|
||||
savedHost: saved ? library.host(candidateHost.id) : nil,
|
||||
isRuntimeActive: false)
|
||||
}
|
||||
#expect(state(rule, host).canStart)
|
||||
#expect(state(rule, host).canEdit)
|
||||
// A nil normalized draft can still use the matching saved configuration.
|
||||
#expect(state(nil, host).canStart)
|
||||
var changedRule = rule
|
||||
changedRule.bindPort += 1
|
||||
#expect(state(changedRule, host).canStart)
|
||||
var changedHost = host
|
||||
changedHost.host = "changed.invalid"
|
||||
#expect(state(rule, changedHost).canStart)
|
||||
changedHost.id = UUID()
|
||||
#expect(!state(rule, changedHost).canStart)
|
||||
let unsavedRule = PortForwardingRule(hostID: host.id)
|
||||
#expect(!state(unsavedRule, host).canStart)
|
||||
let deletedHost = PortForwardingEditorPolicy(
|
||||
rule: rule, savedRule: rule, savedHost: nil,
|
||||
isRuntimeActive: PortForwardingRuntimeState.stopped.isActive)
|
||||
#expect(!deletedHost.canStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
func allActivePhasesLockEditing() {
|
||||
let phases: [PortForwardingRuntimeState] = [
|
||||
.preparing, .awaitingTrust, .awaitingAuthentication, .connecting, .starting,
|
||||
.running(activeConnections: 2),
|
||||
]
|
||||
for phase in phases {
|
||||
let state = PortForwardingEditorPolicy(
|
||||
rule: nil, savedRule: nil, savedHost: nil, isRuntimeActive: phase.isActive)
|
||||
#expect(!state.canStart)
|
||||
#expect(!state.canEdit)
|
||||
}
|
||||
let stopped = PortForwardingEditorPolicy(
|
||||
rule: nil, savedRule: nil, savedHost: nil,
|
||||
isRuntimeActive: PortForwardingRuntimeState.stopped.isActive)
|
||||
#expect(stopped.canEdit)
|
||||
}
|
||||
|
||||
@Test func failedSavedRulesCanBeEditedAndRetried() {
|
||||
let host = Host(name: "Server", host: "server.invalid")
|
||||
let rule = PortForwardingRule(hostID: host.id)
|
||||
let state = PortForwardingEditorPolicy(
|
||||
rule: rule, savedRule: rule, savedHost: host,
|
||||
isRuntimeActive: PortForwardingRuntimeState.failed("Connection refused").isActive)
|
||||
#expect(state.canEdit)
|
||||
#expect(state.canStart)
|
||||
}
|
||||
|
||||
@Test(arguments: [PortForwardingRule.Kind.local, .remote])
|
||||
func normalizationMatchesSavedValuesAndRejectsInvalidFields(kind: PortForwardingRule.Kind) throws {
|
||||
let host = Host(name: "Server", host: "server.invalid")
|
||||
@@ -136,21 +75,19 @@ struct PortForwardingEditorTests {
|
||||
#expect(library.upsert(rule))
|
||||
// Reopening the host editor reloads persisted metadata, including its assigned sort index.
|
||||
let savedHost = try #require(library.host(host.id))
|
||||
let state = PortForwardingEditorPolicy(
|
||||
rule: PortForwardingEditorPolicy.normalized(rule),
|
||||
savedRule: app.forwardings.portForwarding(rule.id),
|
||||
savedHost: app.hostLibrary.host(savedHost.id),
|
||||
isRuntimeActive: app.forwarding.state(for: rule.id).isActive)
|
||||
#expect(state.canStart)
|
||||
#expect(
|
||||
PortForwardingEditorPolicy.canStart(
|
||||
rule.id, savedRule: app.forwardings.portForwarding(rule.id),
|
||||
savedHost: app.hostLibrary.host(savedHost.id),
|
||||
isRuntimeActive: app.forwarding.state(for: rule.id).isActive))
|
||||
#expect(app.forwarding.state(for: rule.id) == .stopped)
|
||||
var remotelyChanged = rule
|
||||
remotelyChanged.bindPort = 9000
|
||||
library.upsert(remotelyChanged)
|
||||
let reopened = PortForwardingEditorPolicy(
|
||||
rule: PortForwardingEditorPolicy.normalized(rule),
|
||||
savedRule: app.forwardings.portForwarding(rule.id),
|
||||
savedHost: app.hostLibrary.host(savedHost.id),
|
||||
isRuntimeActive: app.forwarding.state(for: rule.id).isActive)
|
||||
#expect(reopened.canStart)
|
||||
#expect(
|
||||
PortForwardingEditorPolicy.canStart(
|
||||
rule.id, savedRule: app.forwardings.portForwarding(rule.id),
|
||||
savedHost: app.hostLibrary.host(savedHost.id),
|
||||
isRuntimeActive: app.forwarding.state(for: rule.id).isActive))
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import Foundation
|
||||
import SwiftData
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import TermindPersistence
|
||||
import TermindPresentation
|
||||
import TermindRemoteInfrastructure
|
||||
|
||||
Reference in new issue
Block a user