feat(terminal): add synced keyword highlighting

This commit is contained in:
Kaede Akino committed 2026-10-02 14:01:23 +08:00
1 parent 79bd92436d
commit 7c8592d037
38 files changed
+1614 -15

No files matched your search

@@ -0,0 +1,249 @@
import CloudKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import Testing
@Suite @MainActor
struct TerminalHighlightPreferencesTests {
@Test func defaultsAreStableAndUseSixExistingColors() throws {
let value = TerminalHighlightPreferences()
#expect(value.isEnabled && value.isValid)
#expect(value.rules.isEmpty)
#expect(value.effectiveRules.count == 6)
#expect(value.effectiveRules.map(\.color) == [.red, .yellow, .green, .blue, .purple, .teal])
#expect(value.rules == TerminalHighlightPreferences().rules)
#expect(try JSONDecoder().decode(TerminalHighlightPreferences.self, from: JSONEncoder().encode(value)) == value)
#expect(TerminalHighlightPreferences(rules: []).isValid)
}
@Test func builtInsStayOutsideEditablePreferences() {
let model = TerminalHighlightSettingsModel()
for rule in TerminalHighlightPreferences.builtInRules {
var edited = rule
edited.pattern = "changed"
#expect(!model.upsert(edited, validate: { _ in true }))
#expect(!model.save(.init(rules: [edited])))
}
#expect(model.save(.init(isEnabled: false)))
#expect(!model.preferences.isEnabled)
#expect(model.preferences.rules.isEmpty)
#expect(model.preferences.effectiveRules == TerminalHighlightPreferences.builtInRules)
}
@Test func customRulesRemainEditableAndDeletable() {
let model = TerminalHighlightSettingsModel()
var rule = TerminalHighlightRule(name: "Custom", pattern: "hello", color: .none)
#expect(!rule.isBuiltIn)
#expect(model.upsert(rule, validate: { _ in true }))
rule.name = "Edited"
rule.pattern = "world"
rule.backgroundColor = .pink
#expect(model.upsert(rule, validate: { _ in true }))
#expect(model.preferences.rules.last == rule)
var changed = model.preferences
changed.rules.removeAll { $0.id == rule.id }
#expect(model.save(changed))
#expect(model.preferences == TerminalHighlightPreferences())
}
@Test func noColorIsValidAndDuplicateIDsAndExcessiveRulesAreRejected() throws {
var value = TerminalHighlightPreferences(rules: [.init(name: "Custom", pattern: "x", color: .none)])
#expect(value.isValid)
#expect(
SettingsEnvelope(
key: .init(kind: .terminalHighlighting, id: "default"),
version: .baseline(writerID: UUID()), payload: .terminalHighlighting(value)
).isValid)
value.rules.append(value.rules[0])
#expect(!value.isValid)
value.rules = (0...64).map { .init(name: "Rule \($0)", pattern: "x") }
#expect(!value.isValid)
}
@Test func failedSaveAndInvalidPatternKeepLastCommittedPreferences() {
let store = HighlightStore()
let model = TerminalHighlightSettingsModel(persistence: store)
store.fails = true
#expect(!model.save(.init(isEnabled: false)))
#expect(model.preferences == TerminalHighlightPreferences())
#expect(model.storageError != nil)
#expect(!model.upsert(.init(name: "Invalid", pattern: "[", mode: .regularExpression), validate: { _ in false }))
model.applyRemoteSnapshot(.init(rules: []))
#expect(model.preferences.rules.isEmpty)
#expect(model.storageError == nil)
#expect(store.value == TerminalHighlightPreferences())
}
@Test func independentCloudRecordRoundTripsWithoutEchoOrTerminalSettingLoss() throws {
let domain = "TerminalHighlightSync.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
let sourceContainer = try TermindPersistence.makeContainer(inMemory: true)
let destinationContainer = try TermindPersistence.makeContainer(inMemory: true)
let source = SettingsSyncPersistence(context: sourceContainer.mainContext, defaults: defaults)
let destination = SettingsSyncPersistence(context: destinationContainer.mainContext, defaults: defaults)
try source.prepare()
try destination.prepare()
let value = TerminalHighlightPreferences(
isEnabled: false, rules: [.init(name: "Custom", pattern: "Warn", color: .none, backgroundColor: .blue)])
try source.saveTerminalHighlightPreferences(value)
let key = SettingsRecordKey(kind: .terminalHighlighting, id: "default")
let envelope = try #require(try source.journal.envelope(for: key))
let record = try SettingsSyncCoordinator.makeRecord(
.init(envelope: envelope), recordID: .init(recordName: key.recordName))
#expect(record.recordType == "TerminalHighlightPreferencesItem")
#expect(record["payload"] == nil && record["payloadAsset"] == nil && record["payloadHash"] == nil)
#expect(record["rulePatterns"] as? [String] == ["Warn"])
#expect(record["ruleForegroundColors"] as? [String] == ["none"])
#expect(record["ruleBackgroundColors"] as? [String] == ["blue"])
var callbacks = 0
destination.didRecordChanges = { _ in callbacks += 1 }
let result = try destination.applyRemote([SettingsSyncCoordinator.envelope(from: record)])
#expect(
result.snapshot.terminalHighlighting == value
)
#expect(callbacks == 0)
#expect(!result.needsUpload.contains(key))
// An older app's terminal-theme record cannot drop the independent highlight record.
let legacy = SettingsRecordPayload.terminal(.init(darkTheme: "Dracula"))
_ = try destination.applyRemote([
.init(key: legacy.key, version: .next(after: nil, writerID: UUID()), payload: legacy)
])
#expect(
try destination.loadTerminalHighlightPreferences()
== value)
try destination.saveTerminalHighlightPreferences(.init(rules: []))
try destination.prepare()
#expect(try destination.loadTerminalHighlightPreferences().rules.isEmpty)
#expect(try destination.loadTerminal().darkTheme == "Dracula")
}
@Test func version46StoresEmptyRulesAndBackgroundColorsAfterReopening() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
for value in [
TerminalHighlightPreferences(rules: []),
.init(rules: [.init(name: "Background", pattern: "Warn", color: .none, backgroundColor: .yellow)]),
] {
let url = directory.appendingPathComponent(UUID().uuidString + ".store")
try writeVersion45Store(url)
try saveUpgradedStore(url, value: value)
let reopened = try TermindPersistence.makeContainer(url: url)
let rows = try reopened.mainContext.fetch(FetchDescriptor<PersistentTerminalHighlightPreferences>())
#expect(try rows.first?.load() == value)
let terminal = try reopened.mainContext.fetch(FetchDescriptor<PersistentTerminalPreferences>())
#expect(try terminal.first?.load().copyOnSelect == true)
}
#expect(TermindSchemaV46.models.contains { $0 == PersistentTerminalHighlightPreferences.self })
#expect(!TermindSchemaV45.models.contains { $0 == PersistentTerminalHighlightPreferences.self })
#expect(TermindMigrationPlan.schemas.last?.versionIdentifier == Schema.Version(46, 0, 0))
}
@Test func nativeCloudFieldsRejectMalformedRulesAndPreserveEmptyAndDeletedStates() throws {
let key = SettingsRecordKey(kind: .terminalHighlighting, id: "default")
for value in [TerminalHighlightPreferences(), .init(isEnabled: false, rules: [])] {
let envelope = SettingsEnvelope(
key: key, version: .baseline(writerID: UUID()), payload: .terminalHighlighting(value))
let record = try SettingsSyncCoordinator.makeRecord(
.init(envelope: envelope), recordID: .init(recordName: key.recordName))
#expect(try SettingsSyncCoordinator.envelope(from: record) == envelope)
record["ruleCount"] = NSNumber(value: 65)
#expect(throws: (any Error).self) { try SettingsSyncCoordinator.envelope(from: record) }
}
let deleted = SettingsEnvelope(key: key, version: .baseline(writerID: UUID()), isDeleted: true)
let record = try SettingsSyncCoordinator.makeRecord(
.init(envelope: deleted), recordID: .init(recordName: key.recordName))
#expect(try SettingsSyncCoordinator.envelope(from: record) == deleted)
#expect(record["payload"] == nil)
}
@Test func malformedNativeArraysAreRejectedAndOrderIsPreserved() throws {
let value = TerminalHighlightPreferences(
rules: (0..<6).map { .init(name: "Custom \($0)", pattern: "pattern\($0)") })
let payload = SettingsRecordPayload.terminalHighlighting(value)
let envelope = SettingsEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
let mutations: [(String, any CKRecordValueProtocol)] = [
("ruleIDs", Array(repeating: UUID().uuidString, count: 6) as NSArray),
("rulePatterns", ["only one"] as NSArray),
("ruleBackgroundColors", Array(repeating: "invalid", count: 6) as NSArray),
("ruleCaseSensitive", Array(repeating: NSNumber(value: 2), count: 6) as NSArray),
("formatVersion", NSNumber(value: 999)),
]
for (field, badValue) in mutations {
let record = try SettingsSyncCoordinator.makeRecord(
.init(envelope: envelope), recordID: .init(recordName: payload.key.recordName))
#expect(try SettingsSyncCoordinator.envelope(from: record) == envelope)
record[field] = badValue
#expect(throws: (any Error).self) { try SettingsSyncCoordinator.envelope(from: record) }
}
}
@Test func cloudSyncContainsOnlyCustomRulesAndOneGlobalSwitch() throws {
let domain = "TerminalHighlightCustom.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
let container = try TermindPersistence.makeContainer(inMemory: true)
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
try store.prepare()
let key = SettingsRecordKey(kind: .terminalHighlighting, id: "default")
let initialEnvelope = try #require(try store.journal.envelope(for: key))
#expect(initialEnvelope.payload == .terminalHighlighting(.init(rules: [])))
let initialRecord = try SettingsSyncCoordinator.makeRecord(
.init(envelope: initialEnvelope), recordID: .init(recordName: key.recordName))
#expect(initialRecord["ruleCount"] as? Int == 0)
#expect(initialRecord["ruleIDs"] == nil)
#expect(initialRecord["ruleEnabled"] == nil)
let custom = TerminalHighlightRule(name: "Remote", pattern: "REMOTE", backgroundColor: .yellow)
let remote = TerminalHighlightPreferences(isEnabled: false, rules: [custom])
let envelope = SettingsEnvelope(
key: key, version: .next(after: initialEnvelope.version, writerID: UUID()),
payload: .terminalHighlighting(remote))
let record = try SettingsSyncCoordinator.makeRecord(
.init(envelope: envelope), recordID: .init(recordName: key.recordName))
#expect(record["ruleIDs"] as? [String] == [custom.id.uuidString.lowercased()])
#expect(record["ruleEnabled"] == nil)
#expect(record["enabled"] as? Int == 0)
let result = try store.applyRemote([SettingsSyncCoordinator.envelope(from: record)])
#expect(result.snapshot.terminalHighlighting == remote)
#expect(
result.snapshot.terminalHighlighting.effectiveRules == TerminalHighlightPreferences.builtInRules + [custom])
#expect(!result.needsUpload.contains(key))
let deleted = SettingsEnvelope(
key: key, version: .next(after: envelope.version, writerID: UUID()), isDeleted: true)
let cleared = try store.applyRemote([deleted])
#expect(cleared.snapshot.terminalHighlighting.rules.isEmpty)
#expect(cleared.snapshot.terminalHighlighting.effectiveRules == TerminalHighlightPreferences.builtInRules)
}
private func writeVersion45Store(_ url: URL) throws {
let schema = Schema(versionedSchema: TermindSchemaV45.self)
let container = try ModelContainer(
for: schema,
configurations: [.init("Termind", schema: schema, url: url, cloudKitDatabase: .none)])
container.mainContext.insert(try PersistentTerminalPreferences(.init(copyOnSelect: true)))
try container.mainContext.save()
}
private func saveUpgradedStore(_ url: URL, value: TerminalHighlightPreferences) throws {
let container = try TermindPersistence.makeContainer(url: url)
container.mainContext.insert(try PersistentTerminalHighlightPreferences(value))
try container.mainContext.save()
}
}
@MainActor private final class HighlightStore: TerminalHighlightPreferencesPersisting {
var value = TerminalHighlightPreferences()
var fails = false
func loadTerminalHighlightPreferences() throws -> TerminalHighlightPreferences { value }
func saveTerminalHighlightPreferences(_ value: TerminalHighlightPreferences) throws {
if fails { throw CocoaError(.fileWriteUnknown) }
self.value = value
}
}
+301
View File
@@ -2335,6 +2335,23 @@
}
}
},
"Add Highlight Rule" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "添加高亮规则"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "加入醒目提示規則"
}
}
}
},
"Add Host" : {
"extractionState" : "manual",
"localizations" : {
@@ -4330,6 +4347,29 @@
}
}
},
"Background" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Background"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "背景色"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "背景色"
}
}
}
},
"Basics" : {
"extractionState" : "manual",
"localizations" : {
@@ -4963,6 +5003,23 @@
}
}
},
"Case Sensitive" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "区分大小写"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "區分大小寫"
}
}
}
},
"Caution" : {
"extractionState" : "manual",
"localizations" : {
@@ -10391,6 +10448,23 @@
}
}
},
"Earlier rules take priority. Built-in log keywords skip lowercase words such as error and warn." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "靠前的规则优先。内置日志关键词规则会跳过error和warn等全小写单词。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "靠前的規則優先。內建日誌關鍵字規則會略過error和warn等全小寫單字。"
}
}
}
},
"Earlier turns are summarized for the model. Nothing was deleted." : {
"extractionState" : "manual",
"localizations" : {
@@ -10510,6 +10584,23 @@
}
}
},
"Edit Highlight Rule" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "编辑高亮规则"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "編輯醒目提示規則"
}
}
}
},
"Edit Host" : {
"extractionState" : "manual",
"localizations" : {
@@ -12371,6 +12462,29 @@
}
}
},
"Foreground" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Foreground"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "前景色"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "前景色"
}
}
}
},
"Fork" : {
"extractionState" : "manual",
"localizations" : {
@@ -13660,6 +13774,23 @@
}
}
},
"IP address & MAC" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "IP地址与MAC地址"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "IP位址與MAC位址"
}
}
}
},
"IP address or hostname" : {
"extractionState" : "manual",
"localizations" : {
@@ -14909,6 +15040,23 @@
}
}
},
"Keyword Highlighting" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "关键词高亮"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "關鍵字醒目提示"
}
}
}
},
"Kind" : {
"extractionState" : "manual",
"localizations" : {
@@ -15703,6 +15851,40 @@
}
}
},
"Match Mode" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "匹配方式"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "比對方式"
}
}
}
},
"Match Pattern" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "匹配模式"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "比對模式"
}
}
}
},
"Matches a fork-bomb pattern." : {
"extractionState" : "manual",
"localizations" : {
@@ -16219,6 +16401,40 @@
}
}
},
"Move Rule Down" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "下移规则"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "下移規則"
}
}
}
},
"Move Rule Up" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上移规则"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "上移規則"
}
}
}
},
"Move Workspace to New Window" : {
"extractionState" : "manual",
"localizations" : {
@@ -20149,6 +20365,23 @@
}
}
},
"Plain Text" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "普通文本"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "純文字"
}
}
}
},
"Plan" : {
"extractionState" : "manual",
"localizations" : {
@@ -22024,6 +22257,23 @@
}
}
},
"Regular Expression" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "正则表达式"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "規則運算式"
}
}
}
},
"Relative Path" : {
"extractionState" : "manual",
"localizations" : {
@@ -28712,6 +28962,23 @@
}
}
},
"The match pattern is invalid." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "匹配模式无效。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "比對模式無效。"
}
}
}
},
"The metrics response is incomplete or too large." : {
"extractionState" : "manual",
"localizations" : {
@@ -32504,6 +32771,23 @@
}
}
},
"Use a name up to 256 bytes and a pattern up to 16 KB." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "名称最多256字节,匹配模式最多16KB。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "名稱最多256位元組,比對模式最多16KB。"
}
}
}
},
"Use an installed skill" : {
"comment" : "Command to use an installed skill.",
"extractionState" : "manual",
@@ -33777,6 +34061,23 @@
}
}
},
"You can add up to 58 custom highlight rules." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "最多可添加58条自定义高亮规则。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "最多可加入58條自訂醒目提示規則。"
}
}
}
},
"You stopped after %@" : {
"extractionState" : "manual",
"localizations" : {
@@ -0,0 +1,48 @@
import Foundation
import TermindDomain
@Observable
public final class TerminalHighlightSettingsModel {
public private(set) var preferences = TerminalHighlightPreferences()
public private(set) var storageError: String?
private let persistence: (any TerminalHighlightPreferencesPersisting)?
public init(persistence: (any TerminalHighlightPreferencesPersisting)? = nil) {
self.persistence = persistence
do { if let persistence { preferences = try persistence.loadTerminalHighlightPreferences() } } catch {
storageError = error.localizedDescription
}
}
@discardableResult
public func save(_ next: TerminalHighlightPreferences) -> Bool {
guard next.isValid else { return false }
guard next != preferences else { return true }
do {
try persistence?.saveTerminalHighlightPreferences(next)
applyRemoteSnapshot(next)
return true
} catch {
storageError = error.localizedDescription
return false
}
}
@discardableResult
public func upsert(_ rule: TerminalHighlightRule, validate: (TerminalHighlightRule) -> Bool) -> Bool {
guard !rule.isBuiltIn, rule.isValid, validate(rule) else { return false }
var next = preferences
if let index = next.rules.firstIndex(where: { $0.id == rule.id }) {
next.rules[index] = rule
} else {
next.rules.append(rule)
}
return save(next)
}
public func applyRemoteSnapshot(_ value: TerminalHighlightPreferences) {
guard value.isValid else { return }
preferences = value
storageError = nil
}
}
@@ -0,0 +1,7 @@
import TermindDomain
@MainActor
public protocol TerminalHighlightPreferencesPersisting: AnyObject {
func loadTerminalHighlightPreferences() throws -> TerminalHighlightPreferences
func saveTerminalHighlightPreferences(_ value: TerminalHighlightPreferences) throws
}
@@ -0,0 +1,92 @@
import Foundation
public nonisolated struct TerminalHighlightRule: Codable, Hashable, Sendable, Identifiable {
public enum MatchMode: String, Codable, CaseIterable, Sendable { case text, regularExpression }
public var id: UUID
public var name: String
public var pattern: String
public var mode: MatchMode
public var isCaseSensitive: Bool
public var color: TagColor
public var backgroundColor: TagColor
public init(
id: UUID = UUID(), name: String = "", pattern: String = "", mode: MatchMode = .text,
isCaseSensitive: Bool = false, color: TagColor = .red, backgroundColor: TagColor = .none
) {
self.id = id
self.name = name
self.pattern = pattern
self.mode = mode
self.isCaseSensitive = isCaseSensitive
self.color = color
self.backgroundColor = backgroundColor
}
public var isBuiltIn: Bool { TerminalHighlightPreferences.builtInRules.contains { $0.id == id } }
public var isValid: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !pattern.isEmpty
&& !pattern.contains("\0")
&& pattern.utf8.count <= 16384 && name.utf8.count <= 256
}
}
public nonisolated struct TerminalHighlightPreferences: Codable, Hashable, Sendable {
public static let maximumRuleCount = 64
public static var maximumCustomRuleCount: Int { maximumRuleCount - builtInRules.count }
public var isEnabled: Bool
public var rules: [TerminalHighlightRule]
public init(isEnabled: Bool = true, rules: [TerminalHighlightRule] = []) {
self.isEnabled = isEnabled
self.rules = rules
}
public var isValid: Bool {
rules.count <= Self.maximumCustomRuleCount && rules.allSatisfy { $0.isValid && !$0.isBuiltIn }
&& Set(rules.map(\.id)).count == rules.count
}
/// Built-ins are fixed code definitions and never enter persistence or sync.
public var effectiveRules: [TerminalHighlightRule] { Self.builtInRules + rules }
public static let builtInRules: [TerminalHighlightRule] = {
let words: [(String, String, TagColor)] = [
("Error", "error|err|fatal|critical|fail(?:ed|ure)?|exception", .red),
("Warning", "warn(?:ing)?", .yellow),
("OK", "ok|success(?:ful)?|succeed(?:ed)?|pass(?:ed)?", .green),
("Info", "info(?:rmation)?|notice", .blue),
("Debug", "debug|trace", .purple),
]
// A colon always qualifies. Otherwise, require balanced quoted strings after the keyword.
let outsideQuotes =
#"(?=(?:[^\x22\x27\\\r\n]|\\[^\r\n]|\x22(?:[^\x22\\\r\n]|\\[^\r\n])*\x22|\x27(?:[^\x27\\\r\n]|\\[^\r\n])*\x27)*$)"#
var rules = words.enumerated().map { index, entry in
TerminalHighlightRule(
id: builtInID(index), name: entry.0,
pattern: #"\b(?:"# + entry.1 + #")\b(?=:|"# + outsideQuotes + ")",
mode: .regularExpression, isCaseSensitive: false, color: entry.2)
}
rules.append(
TerminalHighlightRule(
id: builtInID(5), name: "IP address & MAC", pattern: addressPattern,
mode: .regularExpression, isCaseSensitive: false, color: .teal))
return rules
}()
private static func builtInID(_ index: Int) -> UUID {
UUID(uuidString: "57BE4440-7BCD-4C21-923B-00000000000\(index)")!
}
private static let addressPattern: String = {
// Recognize address-shaped text wherever it appears, including quoted values and URLs.
let ipv4 = #"(?:[0-9]{1,3}\.){3}[0-9]{1,3}"#
let hex = "[0-9A-Fa-f]{1,4}"
let groups = "\(hex)(?::\(hex))*"
let ipv6 = "(?:\(hex):){7}\(hex)|(?:\(groups))?::(?:\(groups))?"
let embeddedIPv4 = "(?:\(hex):){6}\(ipv4)|(?:\(groups))?::(?:\(hex):)*\(ipv4)"
let mac = #"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}|(?:[0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}"#
return "(?:\(embeddedIPv4)|\(ipv6)|\(mac)|\(ipv4))"
}()
}
@@ -160,6 +160,7 @@ public nonisolated struct AIWebSearchPreferences: Codable, Hashable, Sendable {
public nonisolated enum SettingsRecordKind: String, Codable, CaseIterable, Sendable {
case provider, mcpServer, skill, role, selection, webSearch, terminal, ssh, sftp, workspace, notifications
case terminalHighlighting
/// `selection` remains decodable for compatibility with existing CloudKit
/// records, but the conversation model picker is a device-local preference.
@@ -197,7 +198,7 @@ public nonisolated struct SettingsRecordKey: Codable, Hashable, Sendable {
guard let uuid = UUID(uuidString: id), uuid.uuidString.lowercased() == id else { return nil }
case .role:
guard AIRole(rawValue: id) != nil || id == Self.legacySecurityReviewID else { return nil }
case .selection, .webSearch, .terminal, .ssh, .sftp, .workspace, .notifications:
case .selection, .webSearch, .terminal, .ssh, .sftp, .workspace, .notifications, .terminalHighlighting:
guard id == "default" else { return nil }
}
self.init(kind: kind, id: id)
@@ -216,6 +217,7 @@ public nonisolated enum SettingsRecordPayload: Codable, Hashable, Sendable {
case sftp(SFTPPreferences)
case workspace(WorkspacePreferences)
case notifications(NotificationPreferences)
case terminalHighlighting(TerminalHighlightPreferences)
public var key: SettingsRecordKey {
switch self {
@@ -230,6 +232,7 @@ public nonisolated enum SettingsRecordPayload: Codable, Hashable, Sendable {
case .sftp: .init(kind: .sftp, id: "default")
case .workspace: .init(kind: .workspace, id: "default")
case .notifications: .init(kind: .notifications, id: "default")
case .terminalHighlighting: .init(kind: .terminalHighlighting, id: "default")
}
}
}
@@ -256,6 +259,7 @@ public nonisolated struct SettingsEnvelope: Codable, Hashable, Sendable {
public var isValid: Bool {
if case .notifications(let value) = payload, !value.isValid { return false }
if case .terminalHighlighting(let value) = payload, !value.isValid { return false }
if case .ssh(let value) = payload, !value.isValid { return false }
if case .sftp(let value) = payload, !value.isValid { return false }
return formatVersion == Self.currentFormatVersion
@@ -15,6 +15,7 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
// Older clients filter by record type before decoding, so they can ignore newer preferences safely.
public nonisolated static let sshRecordType = "SSHPreferencesItem"
public nonisolated static let sftpRecordType = "SFTPPreferencesItem"
public nonisolated static let terminalHighlightRecordType = "TerminalHighlightPreferencesItem"
public nonisolated static let notificationRecordType = "NotificationPreferencesItem"
public nonisolated static let workspaceRecordType = "WorkspacePreferencesItem"
public nonisolated static let subscriptionID = "termind-ai-configuration-v1-changes"
@@ -235,7 +236,11 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
return SettingsRecordKey(recordName: record.recordID.recordName) != nil
},
decode: Self.validatedEnvelope,
quarantine: { try CloudSyncRecordCoding.quarantine($0, error: $1, in: safety) },
quarantine: {
try CloudSyncRecordCoding.quarantine(
$0, error: $1, in: safety,
nativeFields: $0.recordType == Self.terminalHighlightRecordType)
},
logFailure: { lifecycle.logFailure($0, stage: "decode-quarantine") })
let result = try applyRemote(
decoded.map { $0.1.canonicalized }, deletions: deletedKeys,
@@ -308,7 +313,7 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
nonisolated static func acceptsRecordType(_ type: String) -> Bool {
type == recordType || type == sshRecordType || type == sftpRecordType || type == workspaceRecordType
|| type == notificationRecordType
|| type == notificationRecordType || type == terminalHighlightRecordType
}
private nonisolated static func recordType(for kind: SettingsRecordKind) -> String {
@@ -317,6 +322,7 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
case .sftp: sftpRecordType
case .workspace: workspaceRecordType
case .notifications: notificationRecordType
case .terminalHighlighting: terminalHighlightRecordType
default: recordType
}
}
@@ -330,11 +336,15 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
guard record.recordType == recordType(for: envelope.key.kind), record.recordID == recordID else {
throw SettingsSyncPersistenceError.invalidEnvelope(recordID.recordName)
}
let payload = try SettingsSyncCodec.encode(envelope)
CloudSyncRecordCoding.setMetadata(
on: record, formatVersion: envelope.formatVersion, kind: envelope.key.kind.rawValue,
entityID: envelope.key.id, version: envelope.version, isDeleted: envelope.isDeleted)
try CloudSyncRecordCoding.setPayload(payload, on: record, assetURL: assetURL(for: envelope.key))
if envelope.key.kind == .terminalHighlighting {
try TerminalHighlightCloudRecordCoding.encode(envelope, into: record)
} else {
let payload = try SettingsSyncCodec.encode(envelope)
try CloudSyncRecordCoding.setPayload(payload, on: record, assetURL: assetURL(for: envelope.key))
}
return record
}
@@ -343,6 +353,9 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
}
private nonisolated static func validatedEnvelope(from record: CKRecord) throws -> SettingsEnvelope {
if record.recordType == terminalHighlightRecordType {
return try TerminalHighlightCloudRecordCoding.decode(record)
}
let data = try CloudSyncRecordCoding.validatedPayloadData(
from: record, invalidEnvelope: SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName))
try CloudSyncRecordCoding.validateFormat(in: data, supported: SettingsEnvelope.currentFormatVersion)
@@ -0,0 +1,115 @@
import CloudKit
import Foundation
import TermindDomain
import TermindPersistence
/// Native CloudKit columns. Parallel arrays preserve rule order in one atomic record.
nonisolated enum TerminalHighlightCloudRecordCoding {
private static let ruleFields = [
"ruleIDs", "ruleNames", "rulePatterns", "ruleModes", "ruleCaseSensitive",
"ruleForegroundColors", "ruleBackgroundColors",
]
static func encode(_ envelope: SettingsEnvelope, into record: CKRecord) throws {
guard envelope.isValid else { throw invalid(record) }
guard !envelope.isDeleted, case .terminalHighlighting(let value) = envelope.payload else {
record["enabled"] = nil
record["ruleCount"] = nil
for field in ruleFields { record[field] = nil }
return
}
record["enabled"] = NSNumber(value: value.isEnabled)
record["ruleCount"] = NSNumber(value: value.rules.count)
// Empty lists are represented by an explicit count, not missing preferences.
guard !value.rules.isEmpty else {
for field in ruleFields { record[field] = nil }
return
}
record["ruleIDs"] = value.rules.map { $0.id.uuidString.lowercased() } as NSArray
record["ruleNames"] = value.rules.map(\.name) as NSArray
record["rulePatterns"] = value.rules.map(\.pattern) as NSArray
record["ruleModes"] = value.rules.map { $0.mode.rawValue } as NSArray
record["ruleCaseSensitive"] = value.rules.map { NSNumber(value: $0.isCaseSensitive) } as NSArray
record["ruleForegroundColors"] = value.rules.map { $0.color.rawValue } as NSArray
record["ruleBackgroundColors"] = value.rules.map { $0.backgroundColor.rawValue } as NSArray
}
static func decode(_ record: CKRecord) throws -> SettingsEnvelope {
let format = try integer(record["formatVersion"], record: record)
if format > SettingsEnvelope.currentFormatVersion {
throw CloudSyncCompatibilityError.unsupportedFormat(format)
}
let key = SettingsRecordKey(kind: .terminalHighlighting, id: "default")
guard format == SettingsEnvelope.currentFormatVersion,
record.recordID.recordName == key.recordName,
record["kind"] as? String == key.kind.rawValue,
record["entityID"] as? String == key.id,
let date = record["modifiedAt"] as? Date,
let writerText = record["writerID"] as? String, let writer = UUID(uuidString: writerText)
else { throw invalid(record) }
let counter = try integer(record["counter"], record: record)
guard counter >= 0 else { throw invalid(record) }
let deleted = try boolean(record["isDeleted"], record: record)
var envelope = SettingsEnvelope(
key: key, version: .init(modifiedAt: date, counter: UInt64(counter), writerID: writer), isDeleted: deleted)
if deleted { return envelope }
let enabled = try boolean(record["enabled"], record: record)
let count = try integer(record["ruleCount"], record: record)
guard (0...TerminalHighlightPreferences.maximumCustomRuleCount).contains(count) else { throw invalid(record) }
if count == 0 {
guard ruleFields.allSatisfy({ record[$0] == nil || (record[$0] as? NSArray)?.count == 0 }) else {
throw invalid(record)
}
envelope.payload = .terminalHighlighting(.init(isEnabled: enabled, rules: []))
return envelope
}
func strings(_ key: String) throws -> [String] {
guard let values = record[key] as? [String], values.count == count else { throw invalid(record) }
return values
}
func booleans(_ key: String) throws -> [Bool] {
guard let values = record[key] as? [NSNumber], values.count == count else { throw invalid(record) }
return try values.map { try boolean($0, record: record) }
}
let ids = try strings("ruleIDs")
let names = try strings("ruleNames")
let patterns = try strings("rulePatterns")
let modes = try strings("ruleModes")
let foregrounds = try strings("ruleForegroundColors")
let backgrounds = try strings("ruleBackgroundColors")
let cases = try booleans("ruleCaseSensitive")
var rules: [TerminalHighlightRule] = []
for index in 0..<count {
guard let id = UUID(uuidString: ids[index]),
let mode = TerminalHighlightRule.MatchMode(rawValue: modes[index]),
let foreground = TagColor(rawValue: foregrounds[index]),
let background = TagColor(rawValue: backgrounds[index])
else { throw invalid(record) }
rules.append(
.init(
id: id, name: names[index], pattern: patterns[index], mode: mode,
isCaseSensitive: cases[index], color: foreground, backgroundColor: background))
}
envelope.payload = .terminalHighlighting(.init(isEnabled: enabled, rules: rules))
guard envelope.isValid else { throw invalid(record) }
return envelope
}
private static func integer(_ value: (any CKRecordValueProtocol)?, record: CKRecord) throws -> Int {
guard let number = value as? NSNumber, number.doubleValue.isFinite,
number.doubleValue >= 0, number.doubleValue < Double(Int64.max),
number.doubleValue.rounded(.towardZero) == number.doubleValue
else { throw invalid(record) }
return number.intValue
}
private static func boolean(_ value: (any CKRecordValueProtocol)?, record: CKRecord) throws -> Bool {
let number = try integer(value, record: record)
guard number == 0 || number == 1 else { throw invalid(record) }
return number == 1
}
private static func invalid(_ record: CKRecord) -> SettingsSyncPersistenceError {
.invalidEnvelope(record.recordID.recordName)
}
}
@@ -899,6 +899,13 @@ public enum TermindSchemaV45: VersionedSchema {
}
}
public enum TermindSchemaV46: VersionedSchema {
public static let versionIdentifier = Schema.Version(46, 0, 0)
public static var models: [any PersistentModel.Type] {
TermindSchemaV45.models + [PersistentTerminalHighlightPreferences.self]
}
}
public enum TermindMigrationPlan: SchemaMigrationPlan {
public static var schemas: [any VersionedSchema.Type] {
[
@@ -914,6 +921,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
TermindSchemaV34.self, TermindSchemaV35.self, TermindSchemaV36.self, TermindSchemaV37.self,
TermindSchemaV38.self, TermindSchemaV39.self, TermindSchemaV40.self, TermindSchemaV41.self,
TermindSchemaV42.self, TermindSchemaV43.self, TermindSchemaV44.self, TermindSchemaV45.self,
TermindSchemaV46.self,
]
}
@@ -1018,6 +1026,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
MigrationStage.lightweight(fromVersion: TermindSchemaV42.self, toVersion: TermindSchemaV43.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV43.self, toVersion: TermindSchemaV44.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV44.self, toVersion: TermindSchemaV45.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV45.self, toVersion: TermindSchemaV46.self),
]
}
}
@@ -0,0 +1,26 @@
import Foundation
import SwiftData
import TermindDomain
@Model
public final class PersistentTerminalHighlightPreferences {
@Attribute(.unique) public var key = "default"
public var data = Data()
public init(_ value: TerminalHighlightPreferences) throws { try apply(value) }
public func load() throws -> TerminalHighlightPreferences {
let value = try JSONDecoder().decode(TerminalHighlightPreferences.self, from: data)
guard value.isValid else {
throw SettingsSyncPersistenceError.invalidEnvelope("v1/terminalHighlighting/default")
}
return value
}
public func apply(_ value: TerminalHighlightPreferences) throws {
guard value.isValid else {
throw SettingsSyncPersistenceError.invalidEnvelope("v1/terminalHighlighting/default")
}
data = try JSONEncoder().encode(value)
}
}
@@ -98,6 +98,7 @@ public final class SettingsSyncJournal {
SettingsRecordKey(kind: .sftp, id: "default"),
SettingsRecordKey(kind: .workspace, id: "default"),
SettingsRecordKey(kind: .notifications, id: "default"),
SettingsRecordKey(kind: .terminalHighlighting, id: "default"),
] {
let envelope = SettingsEnvelope(
key: key, version: .baseline(writerID: state.installationID), isDeleted: true)
@@ -13,12 +13,13 @@ public nonisolated struct SettingsSyncSnapshot: Sendable {
public var sftp = SFTPPreferences()
public var workspace = WorkspacePreferences()
public var notifications = NotificationPreferences()
public var terminalHighlighting = TerminalHighlightPreferences()
public init(
configuration: AIConfigurationSnapshot, skills: [AgentSkill], webSearch: AIWebSearchPreferences,
terminal: TerminalPreferences = .init(), ssh: SSHPreferences = .init(),
sftp: SFTPPreferences = .init(), workspace: WorkspacePreferences = .init(),
notifications: NotificationPreferences = .init()
notifications: NotificationPreferences = .init(), terminalHighlighting: TerminalHighlightPreferences = .init()
) {
self.configuration = configuration
self.skills = skills
@@ -28,6 +29,7 @@ public nonisolated struct SettingsSyncSnapshot: Sendable {
self.sftp = sftp
self.workspace = workspace
self.notifications = notifications
self.terminalHighlighting = terminalHighlighting
}
public var recordPayloads: [SettingsRecordKey: SettingsRecordPayload] {
@@ -51,6 +53,7 @@ public nonisolated struct SettingsSyncSnapshot: Sendable {
payloads.append(.sftp(sftp))
payloads.append(.workspace(workspace))
payloads.append(.notifications(notifications))
payloads.append(.terminalHighlighting(terminalHighlighting))
return Dictionary(uniqueKeysWithValues: payloads.map { ($0.key, $0) })
}
}
@@ -84,6 +87,7 @@ public final class SettingsSyncPersistence {
for payload in [
SettingsRecordPayload.terminal(previous.terminal), .ssh(previous.ssh), .sftp(previous.sftp),
.workspace(previous.workspace), .notifications(previous.notifications),
.terminalHighlighting(previous.terminalHighlighting),
] {
if try journal.envelope(for: payload.key) == nil {
let version = VaultVersion.baseline(writerID: try journal.state().installationID)
@@ -119,6 +123,7 @@ public final class SettingsSyncPersistence {
try sftpPreferences().apply(snapshot.sftp)
try workspacePreferences().apply(snapshot.workspace)
try notificationPreferences().apply(snapshot.notifications)
try terminalHighlightPreferences().apply(snapshot.terminalHighlighting)
try CloudSyncSafetyStore(context: context, scope: scope).purgeScope()
let cloud = CloudSyncRecordStateStore(context: context, scope: scope)
try cloud.purgeScope()
@@ -142,7 +147,8 @@ public final class SettingsSyncPersistence {
configuration: try ai.load(preservingUnresolvedReferences: true),
skills: try skills.load(), webSearch: try preferences().value, terminal: try terminalPreferences().load(),
ssh: try sshPreferences().load(), sftp: try sftpPreferences().load(),
workspace: try workspacePreferences().load(), notifications: try notificationPreferences().load())
workspace: try workspacePreferences().load(), notifications: try notificationPreferences().load(),
terminalHighlighting: try loadTerminalHighlightPreferences())
}
public func saveConfiguration(_ configuration: AIConfigurationSnapshot) throws {
@@ -225,6 +231,7 @@ public final class SettingsSyncPersistence {
try sftpPreferences().apply(snapshot.sftp)
try workspacePreferences().apply(snapshot.workspace)
try notificationPreferences().apply(snapshot.notifications)
try terminalHighlightPreferences().apply(snapshot.terminalHighlighting)
try journal.markNeedsUpload(merge.needsUpload)
}
mirror(snapshot.webSearch)
@@ -250,6 +257,7 @@ public final class SettingsSyncPersistence {
var sftp = try sftpPreferences().load()
var workspace = try workspacePreferences().load()
var notifications = try notificationPreferences().load()
var terminalHighlighting = try loadTerminalHighlightPreferences()
for envelope in envelopes where envelope.isDeleted {
switch envelope.key.kind {
case .webSearch: webSearch = AIWebSearchPreferences()
@@ -258,6 +266,8 @@ public final class SettingsSyncPersistence {
case .sftp: sftp = SFTPPreferences()
case .workspace: workspace = WorkspacePreferences()
case .notifications: notifications = NotificationPreferences()
case .terminalHighlighting:
terminalHighlighting = TerminalHighlightPreferences()
default: break
}
}
@@ -274,6 +284,8 @@ public final class SettingsSyncPersistence {
case .sftp(let value): sftp = value
case .workspace(let value): workspace = value
case .notifications(let value): notifications = value
case .terminalHighlighting(let value):
terminalHighlighting = value
case nil: break
}
}
@@ -299,7 +311,8 @@ public final class SettingsSyncPersistence {
}
return SettingsSyncSnapshot(
configuration: configuration, skills: skillRows.map(\.0), webSearch: webSearch, terminal: terminal,
ssh: ssh, sftp: sftp, workspace: workspace, notifications: notifications)
ssh: ssh, sftp: sftp, workspace: workspace, notifications: notifications,
terminalHighlighting: terminalHighlighting)
}
private func terminalPreferences() throws -> PersistentTerminalPreferences {
@@ -381,6 +394,24 @@ public final class SettingsSyncPersistence {
try saveLocal { try notificationPreferences().apply(value) }
}
private func terminalHighlightPreferences() throws -> PersistentTerminalHighlightPreferences {
if let row = try context.fetch(FetchDescriptor<PersistentTerminalHighlightPreferences>()).first { return row }
let row = try PersistentTerminalHighlightPreferences(.init())
context.insert(row)
return row
}
public func loadTerminalHighlightPreferences() throws -> TerminalHighlightPreferences {
try terminalHighlightPreferences().load()
}
public func saveTerminalHighlightPreferences(_ value: TerminalHighlightPreferences) throws {
guard value.isValid else {
throw SettingsSyncPersistenceError.invalidEnvelope("v1/terminalHighlighting/default")
}
try saveLocal { try terminalHighlightPreferences().apply(value) }
}
private func preferences() throws -> PersistentAIWebSearchPreferences {
if let row = try context.fetch(FetchDescriptor<PersistentAIWebSearchPreferences>()).first { return row }
let row = PersistentAIWebSearchPreferences(
@@ -425,3 +456,5 @@ extension SettingsSyncPersistence: SFTPPreferencesPersisting {}
extension SettingsSyncPersistence: WorkspacePreferencesPersisting {}
extension SettingsSyncPersistence: NotificationPreferencesPersisting {}
extension SettingsSyncPersistence: TerminalHighlightPreferencesPersisting {}
@@ -20,7 +20,7 @@ public enum TermindPersistence {
#endif
// Local schema migrations do not change the independently versioned CloudKit envelopes.
public static let schema = Schema(versionedSchema: TermindSchemaV45.self)
public static let schema = Schema(versionedSchema: TermindSchemaV46.self)
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
@@ -0,0 +1,30 @@
import Foundation
extension AppStrings {
public nonisolated enum TerminalHighlighting {
public static var title: LocalizedStringResource { "Keyword Highlighting" }
public static var addRule: LocalizedStringResource { "Add Highlight Rule" }
public static var editRule: LocalizedStringResource { "Edit Highlight Rule" }
public static var pattern: LocalizedStringResource { "Match Pattern" }
public static var mode: LocalizedStringResource { "Match Mode" }
public static var text: LocalizedStringResource { "Plain Text" }
public static var regex: LocalizedStringResource { "Regular Expression" }
public static var caseSensitive: LocalizedStringResource { "Case Sensitive" }
public static var explanation: LocalizedStringResource {
"Earlier rules take priority. Built-in log keywords skip lowercase words such as error and warn."
}
public static var invalidPattern: LocalizedStringResource { "The match pattern is invalid." }
public static var limits: LocalizedStringResource { "Use a name up to 256 bytes and a pattern up to 16 KB." }
public static var ruleLimit: LocalizedStringResource { "You can add up to 58 custom highlight rules." }
public static var foreground: LocalizedStringResource { "Foreground" }
public static var background: LocalizedStringResource { "Background" }
public static var moveUp: LocalizedStringResource { "Move Rule Up" }
public static var moveDown: LocalizedStringResource { "Move Rule Down" }
public static var error: LocalizedStringResource { "Error" }
public static var warning: LocalizedStringResource { "Warning" }
public static var ok: LocalizedStringResource { "OK" }
public static var info: LocalizedStringResource { "Info" }
public static var debug: LocalizedStringResource { "Debug" }
public static var addresses: LocalizedStringResource { "IP address & MAC" }
}
}
@@ -0,0 +1,54 @@
import SwiftUI
import TermindDomain
public struct TerminalHighlightRuleFields: View {
@Binding private var rule: TerminalHighlightRule
public init(rule: Binding<TerminalHighlightRule>) { _rule = rule }
private var patternIssue: String? {
rule.pattern.isEmpty ? nil : TerminalKeywordHighlighting.validationIssue(rule)
}
public var body: some View {
Group {
Section {
LabeledContent(AppStrings.Common.name.swiftUIKey) {
TextField(AppStrings.Common.name.swiftUIKey, text: $rule.name)
.labelsHidden().multilineTextAlignment(.trailing)
}
Picker(AppStrings.TerminalHighlighting.mode.swiftUIKey, selection: $rule.mode) {
Text(AppStrings.TerminalHighlighting.text).tag(TerminalHighlightRule.MatchMode.text)
Text(AppStrings.TerminalHighlighting.regex).tag(
TerminalHighlightRule.MatchMode.regularExpression)
}
TextField(AppStrings.TerminalHighlighting.pattern.swiftUIKey, text: $rule.pattern, axis: .vertical)
.labelsHidden().multilineTextAlignment(.leading)
.font(.body.monospaced()).lineLimit(2...6).autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
if let patternIssue {
VStack(alignment: .leading, spacing: 4) {
Text(AppStrings.TerminalHighlighting.invalidPattern).foregroundStyle(.red)
Text(patternIssue).font(.caption.monospaced()).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
Toggle(AppStrings.TerminalHighlighting.caseSensitive.swiftUIKey, isOn: $rule.isCaseSensitive)
}
Section(AppStrings.TerminalHighlighting.foreground.swiftUIKey) {
ColorTagPicker(selection: $rule.color, layout: .touch)
}
Section(AppStrings.TerminalHighlighting.background.swiftUIKey) {
ColorTagPicker(selection: $rule.backgroundColor, layout: .touch)
}
if rule.name.utf8.count > 256 || rule.pattern.utf8.count > 16384 {
Section {
Text(AppStrings.TerminalHighlighting.limits).font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
@@ -0,0 +1,94 @@
import SwiftUI
import TermindApplication
import TermindDomain
public struct TerminalHighlightSettingsSection: View {
private let model: TerminalHighlightSettingsModel
private let showsTitle: Bool
private let edit: (TerminalHighlightRule) -> Void
public init(
model: TerminalHighlightSettingsModel, showsTitle: Bool = true,
edit: @escaping (TerminalHighlightRule) -> Void
) {
self.model = model
self.showsTitle = showsTitle
self.edit = edit
}
public var body: some View {
Section {
Toggle(
AppStrings.Common.enabled.swiftUIKey,
isOn: Binding(
get: { model.preferences.isEnabled },
set: { enabled in update { $0.isEnabled = enabled } }))
ForEach(TerminalHighlightPreferences.builtInRules) { rule in
ruleLabel(rule)
}
ForEach(model.preferences.rules) { rule in
Button {
edit(rule)
} label: {
ruleLabel(rule)
}
.buttonStyle(.plain)
.contextMenu {
Button(AppStrings.Common.edit.swiftUIKey) { edit(rule) }
Button(AppStrings.TerminalHighlighting.moveUp.swiftUIKey) { move(rule.id, by: -1) }
.disabled(model.preferences.rules.first?.id == rule.id)
Button(AppStrings.TerminalHighlighting.moveDown.swiftUIKey) { move(rule.id, by: 1) }
.disabled(model.preferences.rules.last?.id == rule.id)
Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) {
update { $0.rules.removeAll { $0.id == rule.id } }
}
}
}
.onMove { indices, destination in update { $0.rules.move(fromOffsets: indices, toOffset: destination) }
}
FormNavigationAction(title: AppStrings.TerminalHighlighting.addRule) {
edit(TerminalHighlightRule())
}.disabled(model.preferences.rules.count >= TerminalHighlightPreferences.maximumCustomRuleCount)
if model.preferences.rules.count >= TerminalHighlightPreferences.maximumCustomRuleCount {
Text(AppStrings.TerminalHighlighting.ruleLimit).font(.caption).foregroundStyle(.secondary)
}
if let error = model.storageError { Text(error).foregroundStyle(.red) }
} header: {
if showsTitle { Text(AppStrings.TerminalHighlighting.title) }
} footer: {
Text(AppStrings.TerminalHighlighting.explanation)
}
}
private func ruleLabel(_ rule: TerminalHighlightRule) -> some View {
HStack(spacing: 12) {
Image(
systemName: rule.color == .none && rule.backgroundColor == .none
? "circle.dashed" : "circle.fill"
)
.foregroundStyle(rule.color.color ?? rule.backgroundColor.color ?? .secondary)
.frame(width: 14, height: 14)
VStack(alignment: .leading, spacing: 3) {
Text(TerminalKeywordHighlighting.name(rule)).foregroundStyle(.primary)
Text(rule.pattern).font(.caption.monospaced()).foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}.contentShape(.rect)
}
private func update(_ edit: (inout TerminalHighlightPreferences) -> Void) {
var next = model.preferences
edit(&next)
model.save(next)
}
private func move(_ id: UUID, by offset: Int) {
update { value in
guard let index = value.rules.firstIndex(where: { $0.id == id }),
value.rules.indices.contains(index + offset)
else { return }
value.rules.swapAt(index, index + offset)
}
}
}
@@ -0,0 +1,67 @@
import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
@MainActor
public enum TerminalKeywordHighlighting {
public static func runtimeRule(_ rule: TerminalHighlightRule, environment: EnvironmentValues)
-> TerminalKeywordHighlightRule
{
func rgb(_ tag: TagColor) -> UInt32? {
guard let color = tag.color?.resolve(in: environment) else { return nil }
func component(_ value: Float) -> UInt32 { UInt32((min(1, max(0, value)) * 255).rounded()) }
return component(color.red) << 16 | component(color.green) << 8 | component(color.blue)
}
return TerminalKeywordHighlightRule(
pattern: rule.pattern, mode: rule.mode == .text ? .text : .regularExpression,
isCaseSensitive: rule.isCaseSensitive,
foregroundRGB: rgb(rule.color), backgroundRGB: rgb(rule.backgroundColor))
}
public static func validationIssue(_ rule: TerminalHighlightRule) -> String? {
TerminalKeywordHighlightRule(
pattern: rule.pattern, mode: rule.mode == .text ? .text : .regularExpression,
isCaseSensitive: rule.isCaseSensitive, foregroundRGB: 0
).validationIssue()
}
public static func name(_ rule: TerminalHighlightRule) -> String {
let defaults = TerminalHighlightPreferences.builtInRules
guard let index = defaults.firstIndex(where: { $0.id == rule.id && $0.name == rule.name }) else {
return rule.name
}
let titles: [LocalizedStringResource] = [
AppStrings.TerminalHighlighting.error, AppStrings.TerminalHighlighting.warning,
AppStrings.TerminalHighlighting.ok, AppStrings.TerminalHighlighting.info,
AppStrings.TerminalHighlighting.debug, AppStrings.TerminalHighlighting.addresses,
]
return String(localized: titles[index])
}
}
private struct TerminalKeywordHighlightModifier: ViewModifier {
let state: TerminalViewState
let model: TerminalHighlightSettingsModel
@Environment(\.self) private var environment
func body(content: Content) -> some View {
let rules =
model.preferences.isEnabled
? model.preferences.effectiveRules.map {
TerminalKeywordHighlighting.runtimeRule($0, environment: environment)
} : []
content.task(id: rules) {
// Preserve usable rules if a remote pattern is unsupported by this engine version.
state.setKeywordHighlightRules(rules.filter { $0.validationIssue() == nil })
}
}
}
extension View {
public func terminalKeywordHighlighting(state: TerminalViewState, model: TerminalHighlightSettingsModel)
-> some View
{
modifier(TerminalKeywordHighlightModifier(state: state, model: model))
}
}
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
set -euo pipefail
# Build libghostty-spm/BinaryTarget/GhosttyKit.xcframework first using the
# package's build.sh. This stages a local SPM override without changing pins.
script_dir=$(cd "$(dirname "$0")" && pwd)
repo_root=$(cd "$script_dir/.." && pwd)
python3 - "$repo_root" <<'PY'
import shutil
import sys
from pathlib import Path
from xml.sax.saxutils import escape
root = Path(sys.argv[1])
package = root / 'libghostty-spm'
if not (package / 'BinaryTarget/GhosttyKit.xcframework/Info.plist').is_file():
raise SystemExit('Build the local GhosttyKit.xcframework before preparing the workspace.')
staging = root / 'build/KeywordHighlighting'
local_package = staging / 'libghostty-spm'
local_package.mkdir(parents=True, exist_ok=True)
shutil.copy2(package / 'Package.local.swift', local_package / 'Package.swift')
for name in ['Sources', 'Tests', 'BinaryTarget']:
link = local_package / name
if link.is_symlink():
link.unlink()
elif link.exists():
raise SystemExit(f'Refusing to replace non-symlink: {link}')
link.symlink_to(package / name, target_is_directory=True)
workspace = staging / 'Termind.xcworkspace'
resolved_dir = workspace / 'xcshareddata/swiftpm'
resolved_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(root / 'Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', resolved_dir)
(workspace / 'contents.xcworkspacedata').write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n<Workspace version="1.0">\n'
f'<FileRef location="absolute:{escape(str(root / "Termind.xcodeproj"))}"/>\n'
'<FileRef location="group:libghostty-spm"/>\n</Workspace>\n')
print(workspace)
PY
+3
View File
@@ -118,6 +118,7 @@ final class MobileAppModel {
}
}
var error: String?
let terminalHighlightSettings: TerminalHighlightSettingsModel
let terminalSettings: TerminalSettingsModel
let agentForwardingRouter = MobileAgentForwardingRouter()
let sshSettings: SSHSettingsModel
@@ -237,6 +238,7 @@ final class MobileAppModel {
try metricsPersistence.prepare()
let aiSecrets = AISecretStore(
context: settingsPersistence.context, keychain: secrets, defaults: settingsDefaults)
terminalHighlightSettings = TerminalHighlightSettingsModel(persistence: settingsPersistence)
terminalSettings = TerminalSettingsModel(persistence: settingsPersistence)
notificationSettings = NotificationSettingsModel(persistence: settingsPersistence)
sshSettings = SSHSettingsModel(persistence: settingsPersistence)
@@ -411,6 +413,7 @@ final class MobileAppModel {
ai.didChange = { [weak assistant] in assistant?.repairUnavailableModels() }
settingsSync?.didApplyRemote = { [weak self] snapshot, _ in
self?.ai.applyRemoteSnapshot(snapshot.configuration, webSearch: snapshot.webSearch)
self?.terminalHighlightSettings.applyRemoteSnapshot(snapshot.terminalHighlighting)
self?.terminalSettings.applyRemoteSnapshot(snapshot.terminal)
self?.sshSettings.applyRemoteSnapshot(snapshot.ssh)
self?.sftpSettings.applyRemoteSnapshot(snapshot.sftp)
@@ -182,6 +182,7 @@ final class MobileWorkspaceSessionModel {
descriptor: MobileWorkspaceDescriptor(
hostID: host.id, title: host.name, kind: kind),
hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits,
terminalHighlightSettings: app.terminalHighlightSettings,
terminalSettings: app.terminalSettings, commandHistory: app.commandHistory,
sftpHistory: app.sftpHistory, connectionServices: app.workspaceConnectionServices,
transientHost: host,
@@ -295,6 +296,7 @@ final class MobileWorkspaceSessionModel {
}
let workspace = MobileWorkspace(
descriptor: descriptor, hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits,
terminalHighlightSettings: app.terminalHighlightSettings,
terminalSettings: app.terminalSettings, commandHistory: app.commandHistory, sftpHistory: app.sftpHistory,
connectionServices: app.workspaceConnectionServices)
addWorkspaces([workspace])
@@ -334,6 +336,7 @@ final class MobileWorkspaceSessionModel {
descriptor: MobileWorkspaceDescriptor(
hostID: host.id, title: title, kind: kind),
hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits,
terminalHighlightSettings: app.terminalHighlightSettings,
terminalSettings: app.terminalSettings,
commandHistory: app.commandHistory, sftpHistory: app.sftpHistory,
connectionServices: app.workspaceConnectionServices,
@@ -35,7 +35,8 @@ struct MobileSettingsView: View {
Label(AppStrings.Notifications.title, systemImage: "bell.badge")
}
NavigationLink {
MobileTerminalSettingsView(model: app.terminalSettings)
MobileTerminalSettingsView(
model: app.terminalSettings, highlightModel: app.terminalHighlightSettings)
} label: {
Label(AppStrings.Settings.terminal.swiftUIKey, systemImage: "terminal")
}
@@ -0,0 +1,54 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
struct MobileTerminalHighlightRuleEditor: View {
@State private var rule: TerminalHighlightRule
private let original: TerminalHighlightRule
private let model: TerminalHighlightSettingsModel
@Environment(\.dismiss) private var dismiss
init(rule: TerminalHighlightRule, model: TerminalHighlightSettingsModel) {
_rule = State(initialValue: rule)
original = rule
self.model = model
}
private var isNew: Bool { !model.preferences.rules.contains { $0.id == rule.id } }
private var canSave: Bool {
!rule.isBuiltIn && rule.isValid && TerminalKeywordHighlighting.validationIssue(rule) == nil
}
var body: some View {
MobileEditorSheet(
title: String(
localized: isNew ? AppStrings.TerminalHighlighting.addRule : AppStrings.TerminalHighlighting.editRule),
isDirty: rule != original, canSave: canSave,
actions: toolbarActions,
save: {
guard model.upsert(rule, validate: { TerminalKeywordHighlighting.validationIssue($0) == nil }) else {
throw MobileEditorError.message(
model.storageError ?? String(localized: AppStrings.TerminalHighlighting.invalidPattern))
}
},
content: {
TerminalHighlightRuleFields(rule: $rule)
if let error = model.storageError { Section { Text(error).foregroundStyle(.red) } }
}
)
}
private var toolbarActions: [MobileEditorToolbarAction] {
guard !isNew, !rule.isBuiltIn else { return [] }
return [
MobileEditorToolbarAction(
id: "delete", title: AppStrings.Common.delete, systemImage: "trash", role: .destructive,
perform: {
var next = model.preferences
next.rules.removeAll { $0.id == rule.id }
if model.save(next) { dismiss() }
})
]
}
}
@@ -0,0 +1,22 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
struct MobileTerminalHighlightSettingsView: View {
private let model: TerminalHighlightSettingsModel
@State private var editingRule: TerminalHighlightRule?
init(model: TerminalHighlightSettingsModel) { self.model = model }
var body: some View {
Form {
TerminalHighlightSettingsSection(model: model, showsTitle: false) { editingRule = $0 }
}
.formStyle(.grouped)
.navigationTitle(AppStrings.TerminalHighlighting.title.swiftUIKey)
.sheet(item: $editingRule) { rule in
MobileTerminalHighlightRuleEditor(rule: rule, model: model)
}
}
}
@@ -5,9 +5,15 @@ import TermindPresentation
struct MobileTerminalSettingsView: View {
let model: TerminalSettingsModel
let highlightModel: TerminalHighlightSettingsModel
@TerminalFontSettings private var font
@Environment(\.scenePhase) private var scenePhase
init(model: TerminalSettingsModel, highlightModel: TerminalHighlightSettingsModel = .init()) {
self.model = model
self.highlightModel = highlightModel
}
var body: some View {
Form {
themeSection(dark: false)
@@ -15,6 +21,13 @@ struct MobileTerminalSettingsView: View {
Section(AppStrings.Settings.font.swiftUIKey) {
TerminalFontFields(preferences: $font, families: TerminalFontCatalog.shared.families)
}
Section {
NavigationLink {
MobileTerminalHighlightSettingsView(model: highlightModel)
} label: {
Text(AppStrings.TerminalHighlighting.title)
}
}
Section(AppStrings.Settings.integration.swiftUIKey) {
Toggle(AppStrings.Settings.shellIntegration.swiftUIKey, isOn: binding(\.shellIntegration))
}
@@ -98,6 +98,7 @@ final class MobileWorkspace: Identifiable {
private var remoteTransferPreparationID: UUID?
private var pendingDownload: MobilePendingDownload?
private var pendingRemoteCopy: MobilePendingRemoteCopy?
let terminalHighlightSettings: TerminalHighlightSettingsModel
let terminalSettings: TerminalSettingsModel
let commandHistory: CommandHistoryModel
let sftpHistory: SFTPHistoryModel
@@ -246,6 +247,7 @@ final class MobileWorkspace: Identifiable {
descriptor: MobileWorkspaceDescriptor, hosts: any RemoteSessionHostStore,
transfers: SFTPTransferCoordinator? = nil,
sftpEdits: SFTPEditService? = nil,
terminalHighlightSettings: TerminalHighlightSettingsModel = TerminalHighlightSettingsModel(),
terminalSettings: TerminalSettingsModel = TerminalSettingsModel(),
commandHistory: CommandHistoryModel = CommandHistoryModel(),
sftpHistory: SFTPHistoryModel = SFTPHistoryModel(),
@@ -262,6 +264,7 @@ final class MobileWorkspace: Identifiable {
externalSFTPResourcePath: String? = nil,
externalSFTPMustBeDirectory: Bool = false
) {
self.terminalHighlightSettings = terminalHighlightSettings
self.terminalSettings = terminalSettings
self.commandHistory = commandHistory
self.sftpHistory = sftpHistory
@@ -485,6 +485,9 @@ struct MobileWorkspaceView: View {
ZStack {
VStack(spacing: 0) {
TerminalSurfaceView(context: workspace.terminal)
.terminalKeywordHighlighting(
state: workspace.terminal, model: workspace.terminalHighlightSettings
)
.modifier(
MobileTerminalInitialFocus(
terminal: workspace.terminal,
@@ -0,0 +1,51 @@
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
import UIKit
@testable import TermindIOS
@testable import TermindPresentation
@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1"), .serialized)
@MainActor
struct MobileTerminalHighlightingTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func settingsAndEditorFitTheNativeWindow(_ language: String) async throws {
let model = TerminalHighlightSettingsModel()
var backgroundRule = TermindDomain.TerminalHighlightPreferences.builtInRules[1]
backgroundRule.id = UUID()
backgroundRule.color = .none
backgroundRule.backgroundColor = .yellow
#expect(model.upsert(backgroundRule, validate: { _ in true }))
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
window.windowLevel = .alert + 1
defer {
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
let views: [(String, AnyView)] = [
("settings", AnyView(NavigationStack { MobileTerminalHighlightSettingsView(model: model) })),
("editor", AnyView(MobileTerminalHighlightRuleEditor(rule: backgroundRule, model: model))),
]
for (name, view) in views {
let controller = UIHostingController(rootView: view.environment(\.locale, Locale(identifier: language)))
window.rootViewController = controller
window.makeKeyAndVisible()
controller.view.setNeedsLayout()
controller.view.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(400))
controller.view.layoutIfNeeded()
#expect(abs(controller.view.bounds.width - window.bounds.width) < 0.5)
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
let data = try #require(image.pngData())
Attachment.record(data, named: "mobile-keyword-\(name)-\(language)-\(Int(window.bounds.width)).png")
#expect(data.count > 20000)
}
}
}
+4
View File
@@ -120,6 +120,7 @@ final class AppModel {
/// The procedures the user installed for the agent to follow. Its own store
/// rather than more fields on `ai`: that one is about endpoints you spend a
/// key at, this is a library of documents. See `SkillLibraryModel`.
let terminalHighlightSettings: TerminalHighlightSettingsModel
let terminalSettings: TerminalSettingsModel
let sshSettings: SSHSettingsModel
let sftpSettings: SFTPSettingsModel
@@ -177,6 +178,7 @@ final class AppModel {
hosts: HostLibraryModel, ai: AIConfigurationModel = AIConfigurationModel(),
skills: SkillLibraryModel = SkillLibraryModel(),
memory: MemoryModel = MemoryModel(),
terminalHighlightSettings: TerminalHighlightSettingsModel = TerminalHighlightSettingsModel(),
terminalSettings: TerminalSettingsModel = TerminalSettingsModel(),
notificationSettings: NotificationSettingsModel = NotificationSettingsModel(),
sshSettings: SSHSettingsModel = SSHSettingsModel(),
@@ -272,6 +274,7 @@ final class AppModel {
self.ai = ai
self.skills = skills
self.memory = memory
self.terminalHighlightSettings = terminalHighlightSettings
self.terminalSettings = terminalSettings
self.notificationSettings = notificationSettings
self.sshSettings = sshSettings
@@ -395,6 +398,7 @@ final class AppModel {
settingsSync?.didApplyRemote = { [weak self] snapshot, _ in
self?.ai.applyRemoteSnapshot(snapshot.configuration, webSearch: snapshot.webSearch)
self?.skills.applyRemoteSnapshot(snapshot.skills)
self?.terminalHighlightSettings.applyRemoteSnapshot(snapshot.terminalHighlighting)
self?.terminalSettings.applyRemoteSnapshot(snapshot.terminal)
self?.sshSettings.applyRemoteSnapshot(snapshot.ssh)
self?.sftpSettings.applyRemoteSnapshot(snapshot.sftp)
+1
View File
@@ -162,6 +162,7 @@ struct TermindApp: App {
? MemoryModel()
: MemoryModel(
storage: GRDBMemoryStore(directory: GRDBMemoryStore.defaultDirectory), defaults: .standard),
terminalHighlightSettings: TerminalHighlightSettingsModel(persistence: settingsPersistence),
terminalSettings: TerminalSettingsModel(persistence: settingsPersistence),
notificationSettings: NotificationSettingsModel(persistence: settingsPersistence),
sshSettings: SSHSettingsModel(persistence: settingsPersistence),
+10 -2
View File
@@ -66,6 +66,7 @@ enum SettingsRoute: Hashable {
case mcpServer(MCPServer.ID, isNew: Bool)
case localMCPServer
case skill(AgentSkill.ID, isNew: Bool)
case terminalHighlightRule(TerminalHighlightRule)
case terminalThemes(TerminalCustomConfigurationScheme)
case terminalConfiguration(TerminalCustomConfigurationScheme)
case logging
@@ -86,7 +87,7 @@ enum SettingsRoute: Hashable {
.category(.ai)
case .skill:
.category(.ai)
case .terminalThemes:
case .terminalHighlightRule, .terminalThemes:
.category(.terminal)
case .terminalConfiguration(let scheme):
.terminalThemes(scheme)
@@ -147,6 +148,10 @@ struct SettingsPageView: View {
SettingsFormPage { MCPServerSettings() }
case .skill(let id, _):
SkillEditorPage(skillID: id, deleted: { navigate(to: .category(.ai)) })
case .terminalHighlightRule(let rule):
TerminalHighlightRuleEditorPage(rule: rule, model: app.terminalHighlightSettings) {
navigate(to: .category(.terminal))
}.id(rule.id)
case .terminalThemes(let scheme):
TerminalThemePage(scheme: scheme, editConfiguration: { navigate(to: .terminalConfiguration(scheme)) })
case .terminalConfiguration(let scheme):
@@ -175,7 +180,8 @@ struct SettingsPageView: View {
case .terminal:
SettingsFormPage {
TerminalSettings(
chooseTheme: { navigate(to: .terminalThemes($0)) }
chooseTheme: { navigate(to: .terminalThemes($0)) },
editHighlighting: { navigate(to: .terminalHighlightRule($0)) }
)
}
case .connections:
@@ -583,6 +589,7 @@ private struct ConnectionSettings: View {
private struct TerminalSettings: View {
let chooseTheme: (TerminalCustomConfigurationScheme) -> Void
let editHighlighting: (TerminalHighlightRule) -> Void
@Environment(AppModel.self) private var app
@TerminalFontSettings private var font
@Environment(\.scenePhase) private var scenePhase
@@ -654,6 +661,7 @@ private struct TerminalSettings: View {
AppStrings.Settings.showTerminalTitleInMenuBar.swiftUIKey,
isOn: binding(\.remoteWindowTitleInMenuBar))
}
TerminalHighlightSettingsSection(model: app.terminalHighlightSettings, edit: editHighlighting)
}
.onAppear {
TerminalFontCatalog.shared.refresh()
@@ -0,0 +1,46 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
/// Uses the same settings-page navigation and immediate persistence as the skill editor.
struct TerminalHighlightRuleEditorPage: View {
@State var rule: TerminalHighlightRule
let model: TerminalHighlightSettingsModel
let deleted: () -> Void
@State private var wasDeleted = false
var body: some View {
SettingsFormPage {
TerminalHighlightRuleFields(rule: $rule)
if let error = model.storageError { Text(error).foregroundStyle(.red) }
if model.preferences.rules.contains(where: { $0.id == rule.id }) {
Section {
HStack {
Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) {
var next = model.preferences
next.rules.removeAll { $0.id == rule.id }
if model.save(next) {
wasDeleted = true
deleted()
}
}
Spacer()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
.task(id: rule) {
do { try await Task.sleep(for: .milliseconds(150)) } catch { return }
saveIfValid()
}
.onDisappear { saveIfValid() }
}
private func saveIfValid() {
guard !wasDeleted else { return }
model.upsert(rule, validate: { TerminalKeywordHighlighting.validationIssue($0) == nil })
}
}
+4
View File
@@ -69,6 +69,10 @@ struct SettingsWindowTitleProvider: WindowTitleProviding {
return AppLocalization.resolve(AppStrings.MCP.mcpServer, locale: locale)
case .skill:
return AppLocalization.resolve(AppStrings.Settings.skill, locale: locale)
case .terminalHighlightRule(let rule):
return AppLocalization.resolve(
rule.name.isEmpty ? AppStrings.TerminalHighlighting.addRule : AppStrings.TerminalHighlighting.editRule,
locale: locale)
case .terminalThemes(let scheme):
return AppLocalization.resolve(
scheme == .dark ? AppStrings.Settings.darkTheme : AppStrings.Settings.lightTheme, locale: locale)
@@ -103,10 +103,12 @@ nonisolated final class TerminalInteractiveInputCapture: @unchecked Sendable {
private struct DesktopTerminalChrome: ViewModifier {
let state: TerminalViewState
@TerminalAppearanceSettings private var appearance
@Environment(AppModel.self) private var app
func body(content: Content) -> some View {
content
.background { TerminalBackground(state: state) }
.terminalKeywordHighlighting(state: state, model: app.terminalHighlightSettings)
.onAppear {
state.applyPreferences(appearance.preferences, font: appearance.font)
}
+1
View File
@@ -91,6 +91,7 @@ struct SettingsNavigationTests {
#expect(SettingsRoute.localMCPServer.parent == .category(.ai))
#expect(SettingsRoute.terminalConfiguration(.dark).parent == .terminalThemes(.dark))
#expect(SettingsRoute.terminalThemes(.dark).parent == .category(.terminal))
#expect(SettingsRoute.terminalHighlightRule(.init()).parent == .category(.terminal))
#expect(SettingsRoute.logging.parent == .index)
}
@@ -0,0 +1,199 @@
import AppKit
import Foundation
import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
@testable import Termind
@testable import TermindPresentation
@Suite @MainActor
struct TerminalKeywordHighlightingTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func settingsAndEditorFitTheirOffscreenWindows(_ language: String) async throws {
let model = TerminalHighlightSettingsModel()
var backgroundRule = TermindDomain.TerminalHighlightPreferences.builtInRules[1]
backgroundRule.id = UUID()
backgroundRule.color = .none
backgroundRule.backgroundColor = .yellow
#expect(model.upsert(backgroundRule, validate: { _ in true }))
let views: [(String, AnyView)] = [
("settings", AnyView(SettingsFormPage { TerminalHighlightSettingsSection(model: model) { _ in } })),
("editor", AnyView(TerminalHighlightRuleEditorPage(rule: backgroundRule, model: model, deleted: {}))),
(
"invalid-editor",
AnyView(
TerminalHighlightRuleEditorPage(
rule: .init(name: "Invalid", pattern: "[", mode: .regularExpression), model: model, deleted: {})
)
),
]
for (name, view) in views {
let controller = NSHostingController(rootView: view.environment(\.locale, Locale(identifier: language)))
controller.sizingOptions = []
let window = NSWindow(
contentRect: .init(x: 0, y: 0, width: 600, height: 760),
styleMask: .borderless, backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentViewController = controller
defer {
window.contentViewController = nil
window.close()
}
controller.view.frame = .init(x: 0, y: 0, width: 600, height: 760)
controller.view.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(250))
controller.view.layoutSubtreeIfNeeded()
#expect(controller.view.bounds.width == 600)
#expect(!window.isVisible)
let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds))
controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
Attachment.record(data, named: "keyword-\(name)-\(language).png")
let directory = FileManager.default.temporaryDirectory.appending(path: "termind-keyword-ui")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appending(path: "keyword-\(name)-\(language).png"))
}
}
@Test func builtInRulesCompileUsingTheActualTerminalEngine() {
for rule in TerminalHighlightPreferences.builtInRules {
#expect(TerminalKeywordHighlighting.validationIssue(rule) == nil, "\(rule.name)")
}
}
@Test func logKeywordsMatchAllCasesAndKeepWordBoundaries() throws {
for rule in TerminalHighlightPreferences.builtInRules.prefix(5) {
#expect(!rule.isCaseSensitive)
let expression = try NSRegularExpression(pattern: rule.pattern, options: .caseInsensitive)
for keyword in [rule.name.lowercased(), rule.name.uppercased(), rule.name] {
let text = "\(keyword):"
#expect(expression.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil)
}
}
let warning = TerminalHighlightPreferences.builtInRules[1]
let expression = try NSRegularExpression(pattern: warning.pattern, options: .caseInsensitive)
let text = "Warn Warning WARN WARNING wArN warn warning prewarning WARNINGS"
let words = expression.matches(in: text, range: NSRange(text.startIndex..., in: text)).map {
(text as NSString).substring(with: $0.range)
}
#expect(words == ["Warn", "Warning", "WARN", "WARNING", "wArN", "warn", "warning"])
let error = try NSRegularExpression(
pattern: TerminalHighlightPreferences.builtInRules[0].pattern, options: .caseInsensitive)
let messages = #"msg="xxx error" Error: ERROR msg="Error" error error: errors preerror"#
let matches = error.matches(in: messages, range: NSRange(messages.startIndex..., in: messages))
#expect(
matches.map { (messages as NSString).substring(with: $0.range) }
== ["Error", "ERROR", "error", "error"])
}
@Test func quotedLogKeywordsRequireAnImmediateColon() throws {
for rule in TerminalHighlightPreferences.builtInRules.prefix(5) {
let expression = try NSRegularExpression(pattern: rule.pattern, options: .caseInsensitive)
let keywords = rule.name == "Warning" ? ["warn", "warning"] : [rule.name.lowercased()]
for keyword in keywords {
for (text, expectedCount) in [
("\(keyword) before \"ordinary text\"", 1),
("msg=\"xxx \(keyword)\"", 0),
("msg=\"\(keyword): detail\"", 1),
("msg=\"\(keyword) : detail\"", 0),
("msg='xxx \(keyword)'", 0),
("msg='\(keyword): detail'", 1),
("msg=\"don't \(keyword)\" \(keyword)", 1),
("msg=\"say \\\"\(keyword)\\\" now\" \(keyword)", 1),
("\"\(keyword): \(keyword)\" \(keyword) '\(keyword)'", 2),
] {
let matches = expression.matches(in: text, range: NSRange(text.startIndex..., in: text))
#expect(matches.count == expectedCount, "\(rule.name): \(text)")
#expect(matches.allSatisfy { (text as NSString).substring(with: $0.range) == keyword })
}
}
}
}
@Test func plainTextTreatsUnbalancedRegexSyntaxAsLiteralAndChangingModeClearsTheIssue() {
for pattern in ["(", ")", "[", "{", "*", "\\", "(?i:"] {
let rule = TerminalHighlightRule(name: "Literal", pattern: pattern, mode: .text)
#expect(TerminalKeywordHighlighting.validationIssue(rule) == nil, "\(pattern)")
}
var rule = TerminalHighlightRule(name: "Switch mode", pattern: "(", mode: .regularExpression)
#expect(TerminalKeywordHighlighting.validationIssue(rule) != nil)
rule.mode = .text
#expect(TerminalKeywordHighlighting.validationIssue(rule) == nil)
rule.mode = .regularExpression
for _ in 0..<3 { #expect(TerminalKeywordHighlighting.validationIssue(rule) != nil) }
}
@Test func addressesMatchInsideQuotesURLsAndAdjacentText() throws {
let expression = try NSRegularExpression(pattern: TerminalHighlightPreferences.builtInRules[5].pattern)
for address in [
"192.168.1.20", "::1", "::", "2001:db8::1", "::ffff:192.0.2.1", "aa:bb:cc:dd:ee:ff", "aa-bb-cc-dd-ee-ff",
] {
let range = NSRange(address.startIndex..., in: address)
#expect(expression.firstMatch(in: address, range: range)?.range == range, "\(address)")
}
for (text, expected) in [
(#"msg="192.168.1.20""#, "192.168.1.20"),
("https://192.168.1.20:443/path", "192.168.1.20"),
("prefix192.168.1.20suffix", "192.168.1.20"),
("[2001:db8::1]:443", "2001:db8::1"),
(#"mac="aa:bb:cc:dd:ee:ff""#, "aa:bb:cc:dd:ee:ff"),
("prefixAA-BB-CC-DD-EE-FFsuffix", "AA-BB-CC-DD-EE-FF"),
("999.168.1.20", "999.168.1.20"),
] {
let match = try #require(expression.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)))
#expect((text as NSString).substring(with: match.range) == expected)
}
// Ghostty's file parser has a 4096-byte line buffer, including the config key and colors.
let runtime = TerminalKeywordHighlighting.runtimeRule(
TerminalHighlightPreferences.builtInRules[5], environment: EnvironmentValues())
let state = TerminalViewState(configSource: .none)
#expect(state.setKeywordHighlightRules([runtime]))
#expect(state.renderedConfig.split(separator: "\n").allSatisfy { $0.utf8.count < 4094 })
}
@Test func literalTextIsEscapedAndConfigurationUpdatesPreserveOtherSettings() {
let state = TerminalViewState(configSource: .none)
let rule = TerminalKeywordHighlightRule(pattern: #"[error].*\E"#, foregroundRGB: 0xFF0000)
#expect(rule.validationIssue() == nil)
#expect(state.setKeywordHighlightRules([rule]))
state.setTerminalConfiguration(.init { $0.withFontSize(19) })
#expect(state.keywordHighlightRules == [rule])
#expect(state.renderedConfig.contains("font-size = 19"))
#expect(state.renderedConfig.contains("keyword-highlight = FF0000,-:"))
let invalid = TerminalKeywordHighlightRule(pattern: "[", mode: .regularExpression, foregroundRGB: 0)
#expect(invalid.validationIssue() != nil)
#expect(!state.setKeywordHighlightRules([invalid]))
#expect(state.keywordHighlightRules == [rule])
#expect(state.setKeywordHighlightRules([]))
#expect(!state.renderedConfig.contains("FF0000,-:"))
}
@Test func noColorPreservesForegroundAndBackgroundCanBeSelectedIndependently() {
let environment = EnvironmentValues()
let rule = TerminalHighlightRule(name: "Background", pattern: "Warn", color: .none, backgroundColor: .yellow)
let runtime = TerminalKeywordHighlighting.runtimeRule(rule, environment: environment)
#expect(runtime.foregroundRGB == nil)
#expect(runtime.backgroundRGB != nil)
#expect(
TerminalKeywordHighlighting.runtimeRule(
.init(name: "None", pattern: "x", color: .none), environment: environment
).backgroundRGB == nil)
}
@Test func runtimeColorsResolveFromExistingPaletteForBothAppearances() {
for scheme in [ColorScheme.light, .dark] {
var environment = EnvironmentValues()
environment.colorScheme = scheme
for rule in TerminalHighlightPreferences.builtInRules {
let runtime = TerminalKeywordHighlighting.runtimeRule(rule, environment: environment)
let color = rule.color.color!.resolve(in: environment)
let components = [color.red, color.green, color.blue].map { UInt32(($0 * 255).rounded()) }
#expect(runtime.foregroundRGB == components[0] << 16 | components[1] << 8 | components[2])
}
}
}
}
+1 -1
View File
@@ -2943,7 +2943,7 @@
repositoryURL = "https://github.com/AkinoKaede/libghostty-spm.git";
requirement = {
kind = revision;
revision = 2260cb098dfb7feba0e52789d7d88234bc36626d;
revision = 08062f4791ac11785db665d21b50fa8b293fcc74;
};
};
TRMD00000000000000100023 /* XCRemoteSwiftPackageReference "openssl-spm" */ = {
@@ -168,7 +168,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/AkinoKaede/libghostty-spm.git",
"state" : {
"revision" : "2260cb098dfb7feba0e52789d7d88234bc36626d"
"revision" : "08062f4791ac11785db665d21b50fa8b293fcc74"
}
},
{