Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 98 additions & 12 deletions Sources/CodecCore/CommandTimeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,95 @@ public struct CapturedOutput: Codable, Equatable, Sendable {

/// The first line holding `query`, ignoring case, and how far into the output it is.
public func firstLine(matching query: String) -> (line: String, index: Int)? {
guard !query.isEmpty else { return nil }
var index = 0
for line in text.split(separator: "\n", omittingEmptySubsequences: false) {
if line.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil {
return (line.trimmingCharacters(in: .whitespaces), index)
guard let expression = TextSearch.expression(for: query, options: FindOptions()) else { return nil }
return firstMatch(of: expression).map { ($0.line, $0.index) }
}

/// The first line `expression` matches in, with the lines around it.
public func firstMatch(of expression: NSRegularExpression) -> OutputMatch? {
let string = text as NSString
let found = expression.rangeOfFirstMatch(in: text, range: NSRange(location: 0, length: string.length))
guard found.location != NSNotFound, found.length > 0 else { return nil }
let lineRange = string.lineRange(for: NSRange(location: found.location, length: 0))
let index = string.substring(to: lineRange.location).utf16.reduce(0) { $0 + ($1 == 0x0A ? 1 : 0) }
let line = string.substring(with: lineRange).trimmingCharacters(in: .newlines) as NSString
let start = min(found.location - lineRange.location, line.length)
let end = min(NSMaxRange(found) - lineRange.location, line.length)
// Counted in characters, in the line without the spaces around it.
let indent = (line as String).prefix { $0.isWhitespace }.count
let trimmed = (line as String).trimmingCharacters(in: .whitespaces)
let lower = min(max(line.substring(to: start).count - indent, 0), trimmed.count)
let upper = min(lower + line.substring(with: NSRange(location: start, length: end - start)).count, trimmed.count)
/// The nearest line with something on it, going up or down from the one at `location`.
func neighbor(_ location: Int, up: Bool) -> String? {
var location = location
while location >= 0, location < string.length {
let range = string.lineRange(for: NSRange(location: location, length: 0))
let text = string.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty { return text }
location = up ? range.location - 1 : NSMaxRange(range)
}
index += 1
return nil
}
return nil
return OutputMatch(line: trimmed, index: index,
range: lower..<upper,
before: neighbor(lineRange.location - 1, up: true),
after: neighbor(NSMaxRange(lineRange), up: false))
}
}

/// A line of a command's output that holds what was searched for.
public struct OutputMatch: Equatable, Sendable {
/// The line, without the spaces around it.
public var line: String
/// How far into the output it is, from 0.
public var index: Int
/// Where the match is in `line`, in characters.
public var range: Range<Int>
/// The nearest lines above and below it with something on them.
public var before: String?
public var after: String?

public init(line: String, index: Int, range: Range<Int>, before: String? = nil, after: String? = nil) {
self.line = line
self.index = index
self.range = range
self.before = before
self.after = after
}

/// The line from a little before the match, so a narrow list still shows the match: a long
/// line's start gives way to "…".
public func excerpt(leading: Int = 16) -> String {
guard range.lowerBound > leading + 1 else { return line }
return "…" + line.dropFirst(range.lowerBound - leading)
}
}

/// What the Commands sidebar filters by: text, matched as Find's options say, and whether to keep
/// failed commands only.
public struct TimelineFilter: Equatable, Sendable {
public var query: String
public var options: FindOptions
public var failedOnly: Bool

public init(query: String = "", options: FindOptions = FindOptions(), failedOnly: Bool = false) {
self.query = query
self.options = options
self.failedOnly = failedOnly
}

/// The query without the spaces around it.
public var text: String { query.trimmingCharacters(in: .whitespaces) }

/// What finds the query; nil when there's none, or it isn't a valid pattern.
public var expression: NSRegularExpression? { TextSearch.expression(for: text, options: options) }

/// There's a query, but it isn't a valid regular expression.
public var isInvalid: Bool { !text.isEmpty && expression == nil }

func matchesCommand(_ command: String, expression: NSRegularExpression) -> Bool {
expression.rangeOfFirstMatch(in: command, range: NSRange(location: 0, length: (command as NSString).length)).length > 0
}
}

Expand Down Expand Up @@ -206,12 +286,18 @@ public struct CommandTimeline: Codable, Equatable, Sendable {

/// Entries whose command or output holds `query` (ignoring case), optionally only failed ones.
public func entries(matching query: String, failedOnly: Bool = false) -> [TimelineEntry] {
let query = query.trimmingCharacters(in: .whitespaces)
entries(matching: TimelineFilter(query: query, failedOnly: failedOnly))
}

/// Entries the filter lets through: whose command or output matches, as Find's options say. None
/// when the query isn't a valid pattern.
public func entries(matching filter: TimelineFilter) -> [TimelineEntry] {
let expression = filter.expression
if expression == nil, !filter.text.isEmpty { return [] }
return entries.filter { entry in
if failedOnly, !entry.failed { return false }
guard !query.isEmpty else { return true }
return entry.command.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
|| entry.output?.firstLine(matching: query) != nil
if filter.failedOnly, !entry.failed { return false }
guard let expression else { return true }
return filter.matchesCommand(entry.command, expression: expression) || entry.output?.firstMatch(of: expression) != nil
}
}

Expand Down
36 changes: 36 additions & 0 deletions Sources/CodecCore/SplitLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,42 @@ public indirect enum SplitLayout<Leaf: AnyObject> {
}
}

/// What to call each leaf, in reading order, by where it is: "left", "top right". Nil for a lone
/// leaf, which needs no name. When places can't be told apart in a few words (splits of more than
/// three, or nested three deep), the leaves are numbered instead: "pane 1", "pane 2".
public var placeNames: [String?] {
guard case .split = self else { return [nil] }
let numbered = leaves.indices.map { Optional("pane \($0 + 1)") }
guard let places = places(), Set(places).count == places.count else { return numbered }
return places
}

/// Each leaf's place as words for across and down, or nil when one leaf would need two of either.
private func places() -> [String]? {
func walk(_ node: SplitLayout, across: String?, down: String?) -> [String]? {
guard case .split(let axis, let children) = node else {
return [[down, across].compactMap { $0 }.joined(separator: " ")]
}
let words: [String]
switch (axis, children.count) {
case (.horizontal, 2): words = ["left", "right"]
case (.horizontal, 3): words = ["left", "middle", "right"]
case (.vertical, 2): words = ["top", "bottom"]
case (.vertical, 3): words = ["top", "middle", "bottom"]
default: return nil
}
if axis == .horizontal ? across != nil : down != nil { return nil }
var names: [String] = []
for (child, word) in zip(children, words) {
guard let inner = walk(child, across: axis == .horizontal ? word : across,
down: axis == .vertical ? word : down) else { return nil }
names += inner
}
return names
}
return walk(self, across: nil, down: nil)
}

private func isLeaf(_ target: Leaf) -> Bool {
if case .leaf(let leaf) = self { return leaf === target }
return false
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodecUI/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation,

// MARK: - Windows

var terminalWindows: [TerminalWindowController] { windows }

private var currentWindow: TerminalWindowController? {
windows.first { $0.window === NSApp.keyWindow } ?? windows.first { $0.window === NSApp.mainWindow }
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodecUI/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ protocol AppCoordinating: AnyObject {
func showSettings()
/// A pane's needs-attention state changed; refresh the Dock badge.
func attentionDidChange()
/// Every window, oldest first, for searching every pane's commands.
var terminalWindows: [TerminalWindowController] { get }
}

/// The services a window and its panes depend on. The app builds one with the real implementations;
Expand Down
25 changes: 11 additions & 14 deletions Sources/CodecUI/CommandOutputViewer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,21 @@ final class CommandOutputViewer: NSViewController {
let entry: TimelineEntry
let output: String?
private let inScrollback: Bool
private let query: String
/// What the list was filtered by, highlighted in the output.
private let expression: NSRegularExpression?
private let theme: CodecTheme
private let codeFont: NSFont
let actions: Actions
private let canInsert: Bool
let explanation: FailureExplanationModel
let textView = OutputTextView()

init(entry: TimelineEntry, output: String?, inScrollback: Bool, query: String, theme: CodecTheme, codeFont: NSFont,
init(entry: TimelineEntry, output: String?, inScrollback: Bool, expression: NSRegularExpression?, theme: CodecTheme, codeFont: NSFont,
actions: Actions, canInsert: Bool, explanation: FailureExplanationModel) {
self.entry = entry
self.output = output
self.inScrollback = inScrollback
self.query = query
self.expression = expression
self.theme = theme
self.codeFont = codeFont
self.actions = actions
Expand Down Expand Up @@ -87,14 +88,10 @@ final class CommandOutputViewer: NSViewController {
let text = NSMutableAttributedString(
string: output ?? placeholder,
attributes: [.font: font, .foregroundColor: output == nil ? NSColor.secondaryLabelColor : theme.foreground])
if let output, !query.isEmpty {
let string = output as NSString
var range = NSRange(location: 0, length: string.length)
while true {
let found = string.range(of: query, options: [.caseInsensitive, .diacriticInsensitive], range: range)
guard found.location != NSNotFound else { break }
text.addAttribute(.backgroundColor, value: theme.accent.withAlphaComponent(0.35), range: found)
range = NSRange(location: found.upperBound, length: string.length - found.upperBound)
if let output, let expression {
for found in expression.matches(in: output, range: NSRange(location: 0, length: (output as NSString).length))
where found.range.length > 0 {
text.addAttribute(.backgroundColor, value: theme.accent.withAlphaComponent(0.35), range: found.range)
}
}
textView.textStorage?.setAttributedString(text)
Expand Down Expand Up @@ -138,9 +135,9 @@ final class CommandOutputViewer: NSViewController {
}

private func scrollToFirstMatch() {
guard let output, !query.isEmpty else { return }
let found = (output as NSString).range(of: query, options: [.caseInsensitive, .diacriticInsensitive])
guard found.location != NSNotFound else { return }
guard let output, let expression else { return }
let found = expression.rangeOfFirstMatch(in: output, range: NSRange(location: 0, length: (output as NSString).length))
guard found.location != NSNotFound, found.length > 0 else { return }
textView.scrollRangeToVisible(found)
textView.showFindIndicator(for: found)
}
Expand Down
Loading
Loading