Remove useless code

This commit is contained in:
Kaede Akino committed 2026-09-19 12:30:16 +08:00
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)
}
+18
View File
@@ -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
-39
View File
@@ -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))))
}
}
-12
View File
@@ -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([]))
}
}
-2
View File
@@ -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 {
-1
View File
@@ -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)
+2 -22
View File
@@ -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 },
+5 -276
View File
@@ -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)
}
}
-125
View File
@@ -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
+1 -2
View File
@@ -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"))
}
+4 -13
View File
@@ -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
+4 -9
View File
@@ -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)
}
}
+1 -1
View File
@@ -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()
+8 -32
View File
@@ -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
+1 -1
View File
@@ -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: {
+1 -3
View File
@@ -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
+1 -3
View File
@@ -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: {
+2 -4
View File
@@ -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 }
-23
View File
@@ -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
View File
@@ -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 {
+33
View File
@@ -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 {
+10 -73
View File
@@ -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