feat(terminal): add synced keyword highlighting
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Executable
+38
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
Submodule libghostty-spm updated: 2260cb098d...08062f4791.
Reference in new issue
Block a user