From 7c8592d03739b51a0b2947dce3eac9d5e337faa4 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 2 Oct 2026 14:01:23 +0800 Subject: [PATCH] feat(terminal): add synced keyword highlighting --- .../TerminalHighlightPreferencesTests.swift | 249 +++++++++++++++ Frameworks/Resources/Localizable.xcstrings | 301 ++++++++++++++++++ .../TerminalHighlightSettingsModel.swift | 48 +++ ...rminalHighlightPreferencesPersisting.swift | 7 + .../TerminalHighlightPreferences.swift | 92 ++++++ .../Settings/SettingsSyncTypes.swift | 6 +- .../Cloud/SettingsSyncCoordinator.swift | 21 +- .../TerminalHighlightCloudRecordCoding.swift | 115 +++++++ .../TermindPersistence/HostPersistence.swift | 9 + ...rsistentTerminalHighlightPreferences.swift | 26 ++ .../SettingsSyncJournalStore.swift | 1 + .../SettingsSyncStore.swift | 39 ++- .../TermindPersistence.swift | 2 +- .../Strings/TerminalHighlighting.swift | 30 ++ .../TerminalHighlightRuleFields.swift | 54 ++++ .../TerminalHighlightSettingsSection.swift | 94 ++++++ .../TerminalKeywordHighlighting.swift | 67 ++++ Scripts/prepare-local-ghostty-workspace.sh | 38 +++ TFI/App/MobileAppModel.swift | 3 + TFI/App/MobileWorkspaceSessionModel.swift | 3 + .../Settings/MobileSettingsView.swift | 3 +- .../MobileTerminalHighlightRuleEditor.swift | 54 ++++ .../MobileTerminalHighlightSettingsView.swift | 22 ++ .../Settings/MobileTerminalSettingsView.swift | 13 + TFI/Features/Workspaces/MobileWorkspace.swift | 3 + .../Workspaces/MobileWorkspaceView.swift | 3 + .../MobileTerminalHighlightingTests.swift | 51 +++ TFM/App/AppModel.swift | 4 + TFM/App/TermindApp.swift | 1 + TFM/Features/Settings/SettingsPageView.swift | 12 +- .../TerminalHighlightRuleEditorPage.swift | 46 +++ TFM/Features/Shell/WindowTitle.swift | 4 + .../Terminal/DesktopTerminalSurface.swift | 2 + TFMTests/SettingsNavigationTests.swift | 1 + .../TerminalKeywordHighlightingTests.swift | 199 ++++++++++++ Termind.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- libghostty-spm | 2 +- 38 files changed, 1614 insertions(+), 15 deletions(-) create mode 100644 FrameworkTests/TerminalHighlightPreferencesTests.swift create mode 100644 Frameworks/TermindApplication/FeatureModels/TerminalHighlightSettingsModel.swift create mode 100644 Frameworks/TermindApplication/TerminalHighlightPreferencesPersisting.swift create mode 100644 Frameworks/TermindDomain/Sessions/TerminalHighlightPreferences.swift create mode 100644 Frameworks/TermindInfrastructure/Cloud/TerminalHighlightCloudRecordCoding.swift create mode 100644 Frameworks/TermindPersistence/PersistentTerminalHighlightPreferences.swift create mode 100644 Frameworks/TermindPresentation/Strings/TerminalHighlighting.swift create mode 100644 Frameworks/TermindPresentation/Terminal/TerminalHighlightRuleFields.swift create mode 100644 Frameworks/TermindPresentation/Terminal/TerminalHighlightSettingsSection.swift create mode 100644 Frameworks/TermindPresentation/Terminal/TerminalKeywordHighlighting.swift create mode 100755 Scripts/prepare-local-ghostty-workspace.sh create mode 100644 TFI/Features/Settings/MobileTerminalHighlightRuleEditor.swift create mode 100644 TFI/Features/Settings/MobileTerminalHighlightSettingsView.swift create mode 100644 TFITests/MobileTerminalHighlightingTests.swift create mode 100644 TFM/Features/Settings/TerminalHighlightRuleEditorPage.swift create mode 100644 TFMTests/TerminalKeywordHighlightingTests.swift diff --git a/FrameworkTests/TerminalHighlightPreferencesTests.swift b/FrameworkTests/TerminalHighlightPreferencesTests.swift new file mode 100644 index 00000000..0412c831 --- /dev/null +++ b/FrameworkTests/TerminalHighlightPreferencesTests.swift @@ -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()) + #expect(try rows.first?.load() == value) + let terminal = try reopened.mainContext.fetch(FetchDescriptor()) + #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 + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 8fba8553..c753cb22 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/TermindApplication/FeatureModels/TerminalHighlightSettingsModel.swift b/Frameworks/TermindApplication/FeatureModels/TerminalHighlightSettingsModel.swift new file mode 100644 index 00000000..1fc49dc9 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/TerminalHighlightSettingsModel.swift @@ -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 + } +} diff --git a/Frameworks/TermindApplication/TerminalHighlightPreferencesPersisting.swift b/Frameworks/TermindApplication/TerminalHighlightPreferencesPersisting.swift new file mode 100644 index 00000000..01c49f11 --- /dev/null +++ b/Frameworks/TermindApplication/TerminalHighlightPreferencesPersisting.swift @@ -0,0 +1,7 @@ +import TermindDomain + +@MainActor +public protocol TerminalHighlightPreferencesPersisting: AnyObject { + func loadTerminalHighlightPreferences() throws -> TerminalHighlightPreferences + func saveTerminalHighlightPreferences(_ value: TerminalHighlightPreferences) throws +} diff --git a/Frameworks/TermindDomain/Sessions/TerminalHighlightPreferences.swift b/Frameworks/TermindDomain/Sessions/TerminalHighlightPreferences.swift new file mode 100644 index 00000000..3f1c8caa --- /dev/null +++ b/Frameworks/TermindDomain/Sessions/TerminalHighlightPreferences.swift @@ -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))" + }() +} diff --git a/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift b/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift index 1b4b0b0f..a2c0811b 100644 --- a/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift +++ b/Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift @@ -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 diff --git a/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift index 9b2873a3..101ea382 100644 --- a/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift +++ b/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift @@ -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) diff --git a/Frameworks/TermindInfrastructure/Cloud/TerminalHighlightCloudRecordCoding.swift b/Frameworks/TermindInfrastructure/Cloud/TerminalHighlightCloudRecordCoding.swift new file mode 100644 index 00000000..cdff3e41 --- /dev/null +++ b/Frameworks/TermindInfrastructure/Cloud/TerminalHighlightCloudRecordCoding.swift @@ -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.. 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) + } +} diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index d8f395a9..a35f2860 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -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), ] } } diff --git a/Frameworks/TermindPersistence/PersistentTerminalHighlightPreferences.swift b/Frameworks/TermindPersistence/PersistentTerminalHighlightPreferences.swift new file mode 100644 index 00000000..28214b95 --- /dev/null +++ b/Frameworks/TermindPersistence/PersistentTerminalHighlightPreferences.swift @@ -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) + } +} diff --git a/Frameworks/TermindPersistence/SettingsSyncJournalStore.swift b/Frameworks/TermindPersistence/SettingsSyncJournalStore.swift index 61b284c3..28e503de 100644 --- a/Frameworks/TermindPersistence/SettingsSyncJournalStore.swift +++ b/Frameworks/TermindPersistence/SettingsSyncJournalStore.swift @@ -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) diff --git a/Frameworks/TermindPersistence/SettingsSyncStore.swift b/Frameworks/TermindPersistence/SettingsSyncStore.swift index 52fd7c6c..ea8d0755 100644 --- a/Frameworks/TermindPersistence/SettingsSyncStore.swift +++ b/Frameworks/TermindPersistence/SettingsSyncStore.swift @@ -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()).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()).first { return row } let row = PersistentAIWebSearchPreferences( @@ -425,3 +456,5 @@ extension SettingsSyncPersistence: SFTPPreferencesPersisting {} extension SettingsSyncPersistence: WorkspacePreferencesPersisting {} extension SettingsSyncPersistence: NotificationPreferencesPersisting {} + +extension SettingsSyncPersistence: TerminalHighlightPreferencesPersisting {} diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 1804c143..0d469747 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -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") diff --git a/Frameworks/TermindPresentation/Strings/TerminalHighlighting.swift b/Frameworks/TermindPresentation/Strings/TerminalHighlighting.swift new file mode 100644 index 00000000..ea92969a --- /dev/null +++ b/Frameworks/TermindPresentation/Strings/TerminalHighlighting.swift @@ -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" } + } +} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalHighlightRuleFields.swift b/Frameworks/TermindPresentation/Terminal/TerminalHighlightRuleFields.swift new file mode 100644 index 00000000..9be66ca0 --- /dev/null +++ b/Frameworks/TermindPresentation/Terminal/TerminalHighlightRuleFields.swift @@ -0,0 +1,54 @@ +import SwiftUI +import TermindDomain + +public struct TerminalHighlightRuleFields: View { + @Binding private var rule: TerminalHighlightRule + + public init(rule: Binding) { _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) + } + } + } + } +} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalHighlightSettingsSection.swift b/Frameworks/TermindPresentation/Terminal/TerminalHighlightSettingsSection.swift new file mode 100644 index 00000000..4e1173a9 --- /dev/null +++ b/Frameworks/TermindPresentation/Terminal/TerminalHighlightSettingsSection.swift @@ -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) + } + } +} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalKeywordHighlighting.swift b/Frameworks/TermindPresentation/Terminal/TerminalKeywordHighlighting.swift new file mode 100644 index 00000000..337fb8d8 --- /dev/null +++ b/Frameworks/TermindPresentation/Terminal/TerminalKeywordHighlighting.swift @@ -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)) + } +} diff --git a/Scripts/prepare-local-ghostty-workspace.sh b/Scripts/prepare-local-ghostty-workspace.sh new file mode 100755 index 00000000..9e018304 --- /dev/null +++ b/Scripts/prepare-local-ghostty-workspace.sh @@ -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( + '\n\n' + f'\n' + '\n\n') +print(workspace) +PY diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 3203abc0..bf59b108 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -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) diff --git a/TFI/App/MobileWorkspaceSessionModel.swift b/TFI/App/MobileWorkspaceSessionModel.swift index 82e7f9e1..302a6fb9 100644 --- a/TFI/App/MobileWorkspaceSessionModel.swift +++ b/TFI/App/MobileWorkspaceSessionModel.swift @@ -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, diff --git a/TFI/Features/Settings/MobileSettingsView.swift b/TFI/Features/Settings/MobileSettingsView.swift index e955059a..5e34b103 100644 --- a/TFI/Features/Settings/MobileSettingsView.swift +++ b/TFI/Features/Settings/MobileSettingsView.swift @@ -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") } diff --git a/TFI/Features/Settings/MobileTerminalHighlightRuleEditor.swift b/TFI/Features/Settings/MobileTerminalHighlightRuleEditor.swift new file mode 100644 index 00000000..6c02e512 --- /dev/null +++ b/TFI/Features/Settings/MobileTerminalHighlightRuleEditor.swift @@ -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() } + }) + ] + } +} diff --git a/TFI/Features/Settings/MobileTerminalHighlightSettingsView.swift b/TFI/Features/Settings/MobileTerminalHighlightSettingsView.swift new file mode 100644 index 00000000..89c57c19 --- /dev/null +++ b/TFI/Features/Settings/MobileTerminalHighlightSettingsView.swift @@ -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) + } + } +} diff --git a/TFI/Features/Settings/MobileTerminalSettingsView.swift b/TFI/Features/Settings/MobileTerminalSettingsView.swift index efdd5b71..90539d15 100644 --- a/TFI/Features/Settings/MobileTerminalSettingsView.swift +++ b/TFI/Features/Settings/MobileTerminalSettingsView.swift @@ -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)) } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index b951b12a..f0a17fcd 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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 diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index d50627e5..a24d0226 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -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, diff --git a/TFITests/MobileTerminalHighlightingTests.swift b/TFITests/MobileTerminalHighlightingTests.swift new file mode 100644 index 00000000..6d092fd7 --- /dev/null +++ b/TFITests/MobileTerminalHighlightingTests.swift @@ -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) + } + } +} diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index d39c323c..f1f1bd32 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -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) diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 0dc4190c..780de715 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -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), diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index ea29a8bd..0f4c2ae2 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -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() diff --git a/TFM/Features/Settings/TerminalHighlightRuleEditorPage.swift b/TFM/Features/Settings/TerminalHighlightRuleEditorPage.swift new file mode 100644 index 00000000..246ea42a --- /dev/null +++ b/TFM/Features/Settings/TerminalHighlightRuleEditorPage.swift @@ -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 }) + } +} diff --git a/TFM/Features/Shell/WindowTitle.swift b/TFM/Features/Shell/WindowTitle.swift index 6fba6d92..34b9f656 100644 --- a/TFM/Features/Shell/WindowTitle.swift +++ b/TFM/Features/Shell/WindowTitle.swift @@ -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) diff --git a/TFM/Features/Terminal/DesktopTerminalSurface.swift b/TFM/Features/Terminal/DesktopTerminalSurface.swift index a79859b7..88951de2 100644 --- a/TFM/Features/Terminal/DesktopTerminalSurface.swift +++ b/TFM/Features/Terminal/DesktopTerminalSurface.swift @@ -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) } diff --git a/TFMTests/SettingsNavigationTests.swift b/TFMTests/SettingsNavigationTests.swift index 8e3434a8..f8ca66ec 100644 --- a/TFMTests/SettingsNavigationTests.swift +++ b/TFMTests/SettingsNavigationTests.swift @@ -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) } diff --git a/TFMTests/TerminalKeywordHighlightingTests.swift b/TFMTests/TerminalKeywordHighlightingTests.swift new file mode 100644 index 00000000..fa2d0159 --- /dev/null +++ b/TFMTests/TerminalKeywordHighlightingTests.swift @@ -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]) + } + } + } +} diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index ac93d31a..cb047c29 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2943,7 +2943,7 @@ repositoryURL = "https://github.com/AkinoKaede/libghostty-spm.git"; requirement = { kind = revision; - revision = 2260cb098dfb7feba0e52789d7d88234bc36626d; + revision = 08062f4791ac11785db665d21b50fa8b293fcc74; }; }; TRMD00000000000000100023 /* XCRemoteSwiftPackageReference "openssl-spm" */ = { diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 12a01706..92055a78 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -168,7 +168,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/AkinoKaede/libghostty-spm.git", "state" : { - "revision" : "2260cb098dfb7feba0e52789d7d88234bc36626d" + "revision" : "08062f4791ac11785db665d21b50fa8b293fcc74" } }, { diff --git a/libghostty-spm b/libghostty-spm index 2260cb09..08062f47 160000 --- a/libghostty-spm +++ b/libghostty-spm @@ -1 +1 @@ -Subproject commit 2260cb098dfb7feba0e52789d7d88234bc36626d +Subproject commit 08062f4791ac11785db665d21b50fa8b293fcc74