diff --git a/Sources/CodecCore/CommandTimeline.swift b/Sources/CodecCore/CommandTimeline.swift index 72bae46..baac1c3 100644 --- a/Sources/CodecCore/CommandTimeline.swift +++ b/Sources/CodecCore/CommandTimeline.swift @@ -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.. + /// 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, 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 } } @@ -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 } } diff --git a/Sources/CodecCore/SplitLayout.swift b/Sources/CodecCore/SplitLayout.swift index 99985b2..2901edd 100644 --- a/Sources/CodecCore/SplitLayout.swift +++ b/Sources/CodecCore/SplitLayout.swift @@ -80,6 +80,42 @@ public indirect enum SplitLayout { } } + /// 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 diff --git a/Sources/CodecUI/AppDelegate.swift b/Sources/CodecUI/AppDelegate.swift index 88b8293..bb50cb8 100644 --- a/Sources/CodecUI/AppDelegate.swift +++ b/Sources/CodecUI/AppDelegate.swift @@ -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 } } diff --git a/Sources/CodecUI/AppEnvironment.swift b/Sources/CodecUI/AppEnvironment.swift index 8083615..2ef17f1 100644 --- a/Sources/CodecUI/AppEnvironment.swift +++ b/Sources/CodecUI/AppEnvironment.swift @@ -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; diff --git a/Sources/CodecUI/CommandOutputViewer.swift b/Sources/CodecUI/CommandOutputViewer.swift index 4129869..a4ffd3f 100644 --- a/Sources/CodecUI/CommandOutputViewer.swift +++ b/Sources/CodecUI/CommandOutputViewer.swift @@ -18,7 +18,8 @@ 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 @@ -26,12 +27,12 @@ final class CommandOutputViewer: NSViewController { 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 @@ -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) @@ -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) } diff --git a/Sources/CodecUI/CommandTimelineSidebar.swift b/Sources/CodecUI/CommandTimelineSidebar.swift index c83d0c8..1342011 100644 --- a/Sources/CodecUI/CommandTimelineSidebar.swift +++ b/Sources/CodecUI/CommandTimelineSidebar.swift @@ -65,8 +65,8 @@ final class PaneTimeline: ObservableObject { } /// Entries the sidebar lists. - func listed(matching query: String, failedOnly: Bool) -> [TimelineEntry] { - value.entries(matching: query, failedOnly: failedOnly).filter { $0.id >= listStart } + func listed(matching filter: TimelineFilter) -> [TimelineEntry] { + value.entries(matching: filter).filter { $0.id >= listStart } } func clearList() { @@ -83,22 +83,51 @@ final class PaneTimeline: ObservableObject { /// The Commands sidebar, on the right of the window: every command run in the focused pane, with how /// it ended and what it printed last. Click one to go back to it in the terminal; Space shows its output, -/// even after it has left the scrollback. +/// even after it has left the scrollback. Switched to All Panes, it lists the commands of every pane in +/// every window, so the filter searches all of their output at once. final class CommandTimelineSidebar: NSView { static let defaultWidth: CGFloat = 280 static let widthRange: ClosedRange = 200...560 + /// Whose commands it lists. + enum Scope: Int { + /// The focused pane's. + case pane + /// Every pane's, in every window, under the pane they ran in. + case all + } + + /// A pane, and where it is, for All Panes. + struct PanePlace { + let pane: Pane + /// "Window 2 › api › left": the window when there are several, the tab, and where the pane is + /// in it when the tab is split. + let title: String + } + let table = TimelineTableView() let searchField = NSSearchField() + let scopeControl = NSSegmentedControl(labels: ["This Pane", "All Panes"], trackingMode: .selectOne, + target: nil, action: nil) + /// Find's options, Aa, ab and .*, for the filter. + let toggles = FindOptionToggles() /// The user dragged the leading edge to this width. var onResize: ((CGFloat) -> Void)? /// Save to Intel, on a command from this pane. var onSaveToIntel: ((TimelineEntry, Pane?) -> Void)? /// What explains failed commands, if Settings allow it. var explainer: () -> (any FailureExplaining)? = { nil } + /// Every pane in every window, in order, for All Panes. + var places: () -> [PanePlace] = { [] } + /// Goes to a command listed in All Panes: to its window, tab and pane, to the line that matched, + /// with Find open there. False when it can't be gone to, as it's no longer in the scrollback. + var onGoToCommand: ((TimelineEntry, Pane, TimelineFilter) -> Bool)? private(set) weak var pane: Pane? + private(set) var scope = Scope.pane private(set) var rows: [Row] = [] + /// The pane each row is from. + private var rowPanes: [Pane] = [] private let header: NSHostingView private let headerModel = TimelineHeaderModel() private let scrollView = NSScrollView() @@ -108,9 +137,13 @@ final class CommandTimelineSidebar: NSView { private var theme: CodecTheme private var codeFont: NSFont private var subscription: AnyCancellable? + /// The panes `subscription` hears from. + private var observed: [ObjectIdentifier] = [] + private var panesChanged: AnyCancellable? + private var reloadPending = false private var clock: Timer? - /// Where the scrollback starts, as of the last reload: commands above it are only in the kept output. - private var scrollbackStart: Int? + /// Where each pane's scrollback starts, as of the last reload: commands above it are only in the kept output. + private var scrollbackStarts: [ObjectIdentifier: Int] = [:] private var outputPopover: NSPopover? /// Whether AppKit is handling a key press; tests stand in for it. var handlingKeyPress: () -> Bool = { NSApp.currentEvent?.type == .keyDown } @@ -120,6 +153,8 @@ final class CommandTimelineSidebar: NSView { enum Row: Equatable { /// Where the commands below ran, and with what Python environment and tool versions. case directory(String, Toolchain?) + /// In All Panes, the pane the commands below ran in: where it is, and its folder now. + case pane(String, directory: String) case entry(TimelineEntry) var entry: TimelineEntry? { @@ -134,7 +169,7 @@ final class CommandTimelineSidebar: NSView { header = NSHostingView(rootView: TimelineHeader(model: headerModel, theme: theme)) super.init(frame: .zero) headerModel.onChange = { [weak self] in self?.reload() } - headerModel.onClear = { [weak self] in self?.pane?.timeline.clearList() } + headerModel.onClear = { [weak self] in self?.clearList() } build() } @@ -154,6 +189,20 @@ final class CommandTimelineSidebar: NSView { searchField.target = self searchField.action = #selector(searchChanged) searchField.setAccessibilityLabel("Filter commands") + toggles.onChange = { [weak self] in self?.searchChanged() } + let options = NSStackView(views: toggles.buttons) + options.orientation = .horizontal + options.spacing = 2 + + scopeControl.controlSize = .small + scopeControl.font = .systemFont(ofSize: 11) + scopeControl.segmentDistribution = .fillEqually + scopeControl.selectedSegment = Scope.pane.rawValue + scopeControl.target = self + scopeControl.action = #selector(scopeChanged) + scopeControl.setToolTip("Commands run in the focused pane", forSegment: Scope.pane.rawValue) + scopeControl.setToolTip("Commands run in every pane, in every window (⇧⌘F)", forSegment: Scope.all.rawValue) + scopeControl.setAccessibilityLabel("Show commands from") let column = NSTableColumn(identifier: .init("command")) column.resizingMask = .autoresizingMask @@ -198,7 +247,7 @@ final class CommandTimelineSidebar: NSView { self.onResize?(self.frame.width - delta) } - for view in [header, searchField, scrollView, emptyLabel, divider, resizeHandle] as [NSView] { + for view in [header, scopeControl, searchField, options, scrollView, emptyLabel, divider, resizeHandle] as [NSView] { view.translatesAutoresizingMaskIntoConstraints = false addSubview(view) } @@ -215,9 +264,14 @@ final class CommandTimelineSidebar: NSView { header.leadingAnchor.constraint(equalTo: divider.trailingAnchor), header.trailingAnchor.constraint(equalTo: trailingAnchor), header.heightAnchor.constraint(equalToConstant: 34), - searchField.topAnchor.constraint(equalTo: header.bottomAnchor), + scopeControl.topAnchor.constraint(equalTo: header.bottomAnchor), + scopeControl.leadingAnchor.constraint(equalTo: divider.trailingAnchor, constant: 10), + scopeControl.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + searchField.topAnchor.constraint(equalTo: scopeControl.bottomAnchor, constant: 8), searchField.leadingAnchor.constraint(equalTo: divider.trailingAnchor, constant: 10), - searchField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + searchField.trailingAnchor.constraint(equalTo: options.leadingAnchor, constant: -4), + options.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + options.centerYAnchor.constraint(equalTo: searchField.centerYAnchor), scrollView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 6), scrollView.leadingAnchor.constraint(equalTo: divider.trailingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), @@ -225,7 +279,7 @@ final class CommandTimelineSidebar: NSView { emptyLabel.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 28), emptyLabel.leadingAnchor.constraint(equalTo: divider.trailingAnchor, constant: 20), emptyLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), - ]) + ] + toggles.sizeConstraints) apply(theme: theme, codeFont: codeFont) } @@ -235,54 +289,144 @@ final class CommandTimelineSidebar: NSView { header.rootView = TimelineHeader(model: headerModel, theme: theme) layer?.backgroundColor = theme.foreground.withAlphaComponent(theme.isDark ? 0.025 : 0.035).cgColor divider.layer?.backgroundColor = theme.divider.cgColor + toggles.apply(accent: theme.accent, dim: .secondaryLabelColor) refreshVisibleRows() } // MARK: Following a pane - /// Shows a pane's commands. Nil while the sidebar is hidden, so it doesn't track anything. + /// Shows a pane's commands. Nil while the sidebar is hidden, so it doesn't track anything. In All + /// Panes, the list stays as it is: the pane is only where Insert Command and Run Again go. func follow(_ pane: Pane?) { guard pane !== self.pane || (pane != nil) != (subscription != nil) else { return } + let listing = subscription != nil self.pane = pane + if scope == .all, pane != nil, listing { return } + outputPopover?.close() + subscribe() + reload(scrollToEnd: true) + } + + /// Lists the focused pane's commands, or every pane's. + func setScope(_ scope: Scope) { + scopeControl.selectedSegment = scope.rawValue + guard scope != self.scope else { return } + self.scope = scope outputPopover?.close() - headerModel.paneTitle = pane?.session.title ?? "" - subscription = pane?.timeline.objectWillChange + // Panes come and go in other windows too. + panesChanged = scope == .all ? NotificationCenter.default.publisher(for: Self.panesDidChange) + .sink { [weak self] _ in MainActor.assumeIsolated { self?.reloadSoon() } } : nil + subscribe() + reload(scrollToEnd: true) + } + + /// ⇧⌘F: every pane's commands, with the keyboard in the filter. + func findInAllPanes() { + setScope(.all) + window?.makeFirstResponder(searchField) + searchField.currentEditor()?.selectAll(nil) + } + + /// Posted when panes were opened or closed, in any window. + static let panesDidChange = Notification.Name("CodecPanesDidChange") + + @objc private func scopeChanged() { + setScope(Scope(rawValue: scopeControl.selectedSegment) ?? .pane) + } + + /// Listens to the panes listed for new commands and output. + private func subscribe(to panes: [Pane]? = nil) { + let panes = pane == nil ? [] : panes ?? (scope == .pane ? [pane].compactMap { $0 } : listedPlaces().map(\.pane)) + observed = panes.map(ObjectIdentifier.init) + subscription = pane == nil ? nil : Publishers.MergeMany(panes.map { $0.timeline.objectWillChange }) .receive(on: RunLoop.main) .sink { [weak self] _ in MainActor.assumeIsolated { self?.reload() } } - reload(scrollToEnd: true) } - private var query: String { searchField.stringValue.trimmingCharacters(in: .whitespaces) } + private func listedPlaces() -> [PanePlace] { + places().filter { !$0.pane.isShutDown } + } + + private func reloadSoon() { + guard !reloadPending else { return } + reloadPending = true + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + self?.reloadPending = false + self?.reload() + } + } + } + + /// What the list is filtered by: the field, Find's options and ⚠. + var filter: TimelineFilter { + TimelineFilter(query: searchField.stringValue, options: toggles.options, failedOnly: headerModel.failedOnly) + } + + private var query: String { filter.text } + + var selectedEntry: TimelineEntry? { entry(at: table.selectedRow) } - var selectedEntry: TimelineEntry? { - rows.indices.contains(table.selectedRow) ? rows[table.selectedRow].entry : nil + /// The pane the selected command ran in. + var selectedPane: Pane? { pane(at: table.selectedRow) } + + /// The row a pane's command is on. + func row(of entry: TimelineEntry, in pane: Pane) -> Int? { + rows.indices.first { rows[$0].entry?.id == entry.id && rowPanes[$0] === pane } } func reload(scrollToEnd: Bool = false) { - let selected = selectedEntry?.id + let selected = selectedEntry.flatMap { entry in selectedPane.map { (entry, $0) } } let atEnd = scrollToEnd || isScrolledToEnd - let entries = pane?.timeline.listed(matching: query, failedOnly: headerModel.failedOnly) ?? [] - var rows: [Row] = [] - var context: (directory: String, toolchain: Toolchain?)? - for entry in entries { - if context?.directory != entry.directory || context?.toolchain != entry.toolchain { - context = (entry.directory, entry.toolchain) - rows.append(.directory(entry.directory, entry.toolchain)) + let filter = self.filter + var rows: [Row] = [], panes: [Pane] = [] + var listed: [Pane] = [] + switch scope { + case .pane: + if let pane { + listed = [pane] + var context: (directory: String, toolchain: Toolchain?)? + for entry in pane.timeline.listed(matching: filter) { + if context?.directory != entry.directory || context?.toolchain != entry.toolchain { + context = (entry.directory, entry.toolchain) + rows.append(.directory(entry.directory, entry.toolchain)) + panes.append(pane) + } + rows.append(.entry(entry)) + panes.append(pane) + } + } + case .all: + let places = pane == nil ? [] : listedPlaces() + listed = places.map(\.pane) + if listed.map(ObjectIdentifier.init) != observed { subscribe(to: listed) } + for place in places { + let entries = place.pane.timeline.listed(matching: filter) + guard !entries.isEmpty else { continue } + rows.append(.pane(place.title, directory: place.pane.session.directory)) + panes.append(place.pane) + for entry in entries { + rows.append(.entry(entry)) + panes.append(place.pane) + } } - rows.append(.entry(entry)) } self.rows = rows - scrollbackStart = pane.flatMap { TerminalGrid(terminal: $0.terminal)?.bufferRowOffset } - headerModel.paneTitle = pane?.session.title ?? "" - headerModel.count = pane?.timeline.listed(matching: "", failedOnly: false).count ?? 0 + rowPanes = panes + scrollbackStarts = [:] + for pane in listed { + scrollbackStarts[ObjectIdentifier(pane)] = TerminalGrid(terminal: pane.terminal)?.bufferRowOffset + } + headerModel.paneTitle = scope == .pane ? pane?.session.title ?? "" : "" + headerModel.count = listed.reduce(0) { $0 + $1.timeline.listed(matching: TimelineFilter()).count } table.reloadData() - if let selected, let row = rows.firstIndex(where: { $0.entry?.id == selected }) { + if let selected, let row = row(of: selected.0, in: selected.1) { restoringSelection = true table.selectRowIndexes([row], byExtendingSelection: false) restoringSelection = false } if atEnd, !rows.isEmpty { table.scrollRowToVisible(rows.count - 1) } - updateEmptyState() + updateEmptyState(listed: listed) updateClock() } @@ -291,23 +435,30 @@ final class CommandTimelineSidebar: NSView { return scrollView.contentView.bounds.maxY >= document.frame.height - 8 } - private func updateEmptyState() { + private func updateEmptyState(listed: [Pane]) { emptyLabel.isHidden = !rows.isEmpty guard rows.isEmpty else { return } + let all = scope == .all if pane == nil { emptyLabel.stringValue = "" - } else if pane?.session.integrated == false { + } else if !listed.contains(where: \.session.integrated) { emptyLabel.stringValue = "Commands show up here with Codec’s zsh integration (Settings › Shell)." } else if headerModel.count == 0 { - emptyLabel.stringValue = "Commands you run in this pane show up here, with what they printed." + emptyLabel.stringValue = all ? "Commands you run in any pane show up here, with what they printed." + : "Commands you run in this pane show up here, with what they printed." + } else if filter.isInvalid { + emptyLabel.stringValue = "“\(query)” isn’t a valid regular expression." + } else if query.isEmpty { + emptyLabel.stringValue = "No failed commands." } else { - emptyLabel.stringValue = query.isEmpty ? "No failed commands." : "No commands or output match “\(query)”." + emptyLabel.stringValue = all ? "No commands or output in any pane match “\(query)”." + : "No commands or output match “\(query)”." } } - /// Ticks the running command's elapsed time. + /// Ticks the running commands' elapsed time. private func updateClock() { - let running = rows.last?.entry?.isRunning == true + let running = rows.contains { $0.entry?.isRunning == true } if running, clock == nil { let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.refreshVisibleRows() } @@ -325,26 +476,31 @@ final class CommandTimelineSidebar: NSView { guard visible.length > 0 else { return } for row in visible.location..<(visible.location + visible.length) where rows.indices.contains(row) { guard let view = table.view(atColumn: 0, row: row, makeIfNecessary: false) else { continue } - configure(view, for: rows[row]) + configure(view, at: row) } } - private func configure(_ view: NSView, for row: Row) { - switch row { + private func configure(_ view: NSView, at row: Int) { + switch rows[row] { case .directory(let directory, let toolchain): (view as? TimelineDirectoryCell)?.show((directory as NSString).abbreviatingWithTildeInPath, toolchain: toolchain, theme: theme) + case .pane(let title, let directory): + (view as? TimelinePaneCell)?.show(title, directory: (directory as NSString).abbreviatingWithTildeInPath) case .entry(let entry): guard let cell = view as? TimelineCell else { return } - cell.show(entry, query: query, inScrollback: isInScrollback(entry), theme: theme, codeFont: codeFont) - cell.onSave = { [weak self] in self?.saveToIntel(entry) } + let pane = rowPanes[row] + cell.show(entry, matching: filter.expression, inScrollback: isInScrollback(entry, in: pane), theme: theme, + codeFont: codeFont) + cell.onSave = { [weak self, weak pane] in self?.saveToIntel(entry, from: pane) } } } /// Whether the command can still be scrolled to, as far as can be told without reading the screen. - func isInScrollback(_ entry: TimelineEntry) -> Bool { + func isInScrollback(_ entry: TimelineEntry, in pane: Pane? = nil) -> Bool { guard let block = entry.block, !entry.fullScreen else { return false } - return scrollbackStart.map { block.prompt >= $0 } ?? true + let start = (pane ?? self.pane).flatMap { scrollbackStarts[ObjectIdentifier($0)] } + return start.map { block.prompt >= $0 } ?? true } // MARK: Going to commands @@ -353,44 +509,58 @@ final class CommandTimelineSidebar: NSView { rows.indices.contains(row) ? rows[row].entry : nil } + private func pane(at row: Int) -> Pane? { + rowPanes.indices.contains(row) ? rowPanes[row] : nil + } + /// A click goes to the command in the terminal, or shows its output when it's no longer there. @objc private func clicked() { - guard let entry = entry(at: table.clickedRow), NSApp.currentEvent?.clickCount ?? 1 == 1 else { return } - if !go(to: entry) { showOutput(of: entry, row: table.clickedRow) } + let row = table.clickedRow + guard let entry = entry(at: row), let pane = pane(at: row), NSApp.currentEvent?.clickCount ?? 1 == 1 else { return } + if !go(to: entry, in: pane) { showOutput(of: entry, in: pane, row: row) } } @objc private func doubleClicked() { - guard let entry = entry(at: table.clickedRow) else { return } - showOutput(of: entry, row: table.clickedRow) + let row = table.clickedRow + guard let entry = entry(at: row) else { return } + showOutput(of: entry, in: pane(at: row), row: row) } + /// Goes to a command in the terminal: in this pane, or, in All Panes, in its own, wherever that is, + /// with Find open there. @discardableResult - func go(to entry: TimelineEntry) -> Bool { - guard let pane else { return false } - let revealed = pane.reveal(entry, matching: query) + func go(to entry: TimelineEntry, in pane: Pane? = nil) -> Bool { + guard let pane = pane ?? self.pane else { return false } + if scope == .all { + guard !pane.isShutDown else { return false } + return onGoToCommand?(entry, pane, filter) ?? false + } + let revealed = pane.reveal(entry, matching: filter.expression) != nil if !revealed { refreshVisibleRows() } return revealed } - /// Return: goes to the selected command and gives the terminal the keyboard. + /// Return: goes to the selected command and gives the terminal the keyboard (in All Panes, Find + /// has it, open where the command is). func goToSelected(focusTerminal focus: Bool) { guard let entry = selectedEntry else { return } - if go(to: entry) { - if focus { focusTerminal() } + let pane = selectedPane + if go(to: entry, in: pane) { + if focus, scope == .pane { focusTerminal() } } else { - showOutput(of: entry, row: table.selectedRow) + showOutput(of: entry, in: pane, row: table.selectedRow) } } private func toggleOutputOfSelected() { if outputPopover?.isShown == true { return outputPopover?.performClose(nil) ?? () } guard let entry = selectedEntry else { return } - showOutput(of: entry, row: table.selectedRow) + showOutput(of: entry, in: selectedPane, row: table.selectedRow) } - func saveToIntel(_ entry: TimelineEntry) { + func saveToIntel(_ entry: TimelineEntry, from pane: Pane? = nil) { outputPopover?.close() - onSaveToIntel?(entry, pane) + onSaveToIntel?(entry, pane ?? self.pane) } func focusTerminal() { @@ -412,23 +582,25 @@ final class CommandTimelineSidebar: NSView { var isShowingOutput: Bool { outputPopover?.isShown == true } var outputViewer: CommandOutputViewer? { outputPopover?.contentViewController as? CommandOutputViewer } - /// Opens the command's output; with `explain`, starts explaining why it failed. - func showOutput(of entry: TimelineEntry, row: Int, explain: Bool = false) { - guard let pane, window != nil else { return } + /// Opens the output of a command run in `pane` (by default the one followed); with `explain`, starts + /// explaining why it failed. Insert and Run Again put the command in the focused pane. + func showOutput(of entry: TimelineEntry, in pane: Pane? = nil, row: Int, explain: Bool = false) { + guard let pane = pane ?? self.pane, let focused = self.pane, window != nil else { return } outputPopover?.close() let popover = NSPopover() popover.behavior = .transient popover.animates = false - let inScrollback = pane.rows(of: entry) != nil + let inScrollback = scope == .all ? isInScrollback(entry, in: pane) && !pane.isShutDown : pane.rows(of: entry) != nil let output = pane.output(of: entry) let explanation = FailureExplanationModel(entry: entry, output: output, explainer: explainer()) let viewer = CommandOutputViewer( - entry: entry, output: output, inScrollback: inScrollback, query: query, + entry: entry, output: output, inScrollback: inScrollback, expression: filter.expression, theme: theme, codeFont: codeFont, actions: .init( - goTo: { [weak self, weak popover] in + goTo: { [weak self, weak popover, weak pane] in popover?.performClose(nil) - if self?.go(to: entry) == true { self?.focusTerminal() } + guard let self, let pane else { return } + if self.go(to: entry, in: pane), self.scope == .pane { self.focusTerminal() } }, insert: { [weak self, weak popover] in popover?.performClose(nil) @@ -439,12 +611,12 @@ final class CommandTimelineSidebar: NSView { self?.insert(entry.command, run: true) }, close: { [weak popover] in popover?.performClose(nil) }, - saveToIntel: { [weak self] in self?.saveToIntel(entry) }, + saveToIntel: { [weak self, weak pane] in self?.saveToIntel(entry, from: pane) }, insertCommand: { [weak self, weak popover] command in popover?.performClose(nil) self?.insert(command, run: false) }), - canInsert: pane.isAtCommandLine, explanation: explanation) + canInsert: focused.isAtCommandLine, explanation: explanation) popover.contentViewController = viewer outputPopover = popover let anchor = row >= 0 && row < table.numberOfRows ? table.rect(ofRow: row) : table.visibleRect @@ -454,10 +626,10 @@ final class CommandTimelineSidebar: NSView { } /// Explain Failure, for a failed command when there's something to explain it with. - private func explainItems(for entry: TimelineEntry, row: Int) -> [NSMenuItem] { + private func explainItems(for entry: TimelineEntry, in pane: Pane?, row: Int) -> [NSMenuItem] { guard let explainer = explainer(), FailureReport(entry: entry, output: nil) != nil else { return [] } - return [ClosureMenuItem("Explain Failure", image: "sparkles", enabled: explainer.availability == .available) { [weak self] in - self?.showOutput(of: entry, row: row, explain: true) + return [ClosureMenuItem("Explain Failure", image: "sparkles", enabled: explainer.availability == .available) { [weak self, weak pane] in + self?.showOutput(of: entry, in: pane, row: row, explain: true) }] } @@ -471,6 +643,14 @@ final class CommandTimelineSidebar: NSView { NSPasteboard.general.setString(text, forType: .string) } + /// The bin: hides the commands listed so far (they're still tracked). + private func clearList() { + switch scope { + case .pane: pane?.timeline.clearList() + case .all: listedPlaces().forEach { $0.pane.timeline.clearList() } + } + } + @objc private func searchChanged() { reload(scrollToEnd: true) } @@ -481,8 +661,8 @@ final class CommandTimelineSidebar: NSView { } /// The row a context menu acts on. - private var menuEntry: TimelineEntry? { - entry(at: table.clickedRow >= 0 ? table.clickedRow : table.selectedRow) + private var menuRow: Int { + table.clickedRow >= 0 ? table.clickedRow : table.selectedRow } } @@ -498,10 +678,12 @@ extension CommandTimelineSidebar: NSTableViewDataSource, NSTableViewDelegate { switch rows[row] { case .directory: view = tableView.makeView(withIdentifier: TimelineDirectoryCell.identifier, owner: nil) ?? TimelineDirectoryCell() + case .pane: + view = tableView.makeView(withIdentifier: TimelinePaneCell.identifier, owner: nil) ?? TimelinePaneCell() case .entry: view = tableView.makeView(withIdentifier: TimelineCell.identifier, owner: nil) ?? TimelineCell() } - configure(view, for: rows[row]) + configure(view, at: row) return view } @@ -512,7 +694,11 @@ extension CommandTimelineSidebar: NSTableViewDataSource, NSTableViewDelegate { } func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { - rows[row].entry == nil ? 22 : 40 + switch rows[row] { + case .directory: 22 + case .pane: 26 + case .entry: 40 + } } func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { @@ -520,9 +706,10 @@ extension CommandTimelineSidebar: NSTableViewDataSource, NSTableViewDelegate { } /// Moving through the list with the arrow keys goes to each command in turn. Only then: a key typed in - /// the terminal is still AppKit's current event when a new command reloads the list. + /// the terminal is still AppKit's current event when a new command reloads the list. Not in All + /// Panes, where going to a command can switch tabs and windows. func tableViewSelectionDidChange(_ notification: Notification) { - guard !restoringSelection, window?.firstResponder === table, handlingKeyPress(), + guard scope == .pane, !restoringSelection, window?.firstResponder === table, handlingKeyPress(), let entry = selectedEntry else { return } go(to: entry) } @@ -535,7 +722,7 @@ extension CommandTimelineSidebar: NSSearchFieldDelegate { switch selector { case #selector(NSResponder.moveDown(_:)), #selector(NSResponder.moveUp(_:)): focusList() - if let entry = selectedEntry { go(to: entry) } + if scope == .pane, let entry = selectedEntry { go(to: entry) } return true case #selector(NSResponder.insertNewline(_:)): if table.selectedRow < 0, let last = rows.lastIndex(where: { $0.entry != nil }) { @@ -557,35 +744,39 @@ extension CommandTimelineSidebar: NSSearchFieldDelegate { extension CommandTimelineSidebar: NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() - guard let entry = menuEntry else { return } - for item in contextMenuItems(for: entry, row: table.clickedRow >= 0 ? table.clickedRow : table.selectedRow) { + guard let entry = entry(at: menuRow) else { return } + for item in contextMenuItems(for: entry, row: menuRow) { menu.addItem(item) } } func contextMenuItems(for entry: TimelineEntry, row: Int) -> [NSMenuItem] { + let pane = pane(at: row) ?? self.pane let output = pane?.liveOutputRange(of: entry) - let canInsert = pane?.isAtCommandLine ?? false + let canInsert = self.pane?.isAtCommandLine ?? false let hasOutput = output != nil || entry.output != nil + // In All Panes, the pane may be in a tab that isn't selected, where its rows can't be measured. + let canGo = scope == .all ? isInScrollback(entry, in: pane) && pane?.isShutDown == false : pane?.rows(of: entry) != nil return [ - ClosureMenuItem("Go to Command", image: "arrow.up.left.and.arrow.down.right", enabled: pane?.rows(of: entry) != nil) { [weak self] in - if self?.go(to: entry) == true { self?.focusTerminal() } + ClosureMenuItem("Go to Command", image: "arrow.up.left.and.arrow.down.right", enabled: canGo) { [weak self, weak pane] in + guard let self, let pane else { return } + if self.go(to: entry, in: pane), self.scope == .pane { self.focusTerminal() } }, - ClosureMenuItem("Show Output", image: "text.alignleft", enabled: hasOutput) { [weak self] in - self?.showOutput(of: entry, row: row) + ClosureMenuItem("Show Output", image: "text.alignleft", enabled: hasOutput) { [weak self, weak pane] in + self?.showOutput(of: entry, in: pane, row: row) }, - ] + explainItems(for: entry, row: row) + [ + ] + explainItems(for: entry, in: pane, row: row) + [ .separator(), - ClosureMenuItem("Save to Intel…", image: "bookmark") { [weak self] in self?.saveToIntel(entry) }, + ClosureMenuItem("Save to Intel…", image: "bookmark") { [weak self, weak pane] in self?.saveToIntel(entry, from: pane) }, .separator(), ClosureMenuItem("Copy Command", image: "doc.on.doc") { [weak self] in self?.copy(entry.command) }, - ClosureMenuItem("Copy Output", image: "doc.on.doc.fill", enabled: hasOutput) { [weak self] in - if let text = self?.pane?.output(of: entry) { self?.copy(text) } + ClosureMenuItem("Copy Output", image: "doc.on.doc.fill", enabled: hasOutput) { [weak self, weak pane] in + if let text = pane?.output(of: entry) { self?.copy(text) } }, - ClosureMenuItem("Select Output", image: "text.line.first.and.arrowtriangle.forward", enabled: output != nil) { [weak self] in - guard let self, let pane = self.pane, let output = pane.liveOutputRange(of: entry) else { return } + ClosureMenuItem("Select Output", image: "text.line.first.and.arrowtriangle.forward", enabled: output != nil) { [weak self, weak pane] in + guard let self, let pane, let output = pane.liveOutputRange(of: entry) else { return } pane.selectOutput(output) - self.focusTerminal() + self.window?.makeFirstResponder(pane.view.preferredResponder) }, .separator(), ClosureMenuItem("Insert Command", image: "text.insert", enabled: canInsert) { [weak self] in @@ -797,6 +988,62 @@ final class TimelineDirectoryCell: NSTableCellView { } } +/// In All Panes, the pane the commands below ran in: where it is ("Window 2 › api › left") and its folder. +final class TimelinePaneCell: NSTableCellView { + static let identifier = NSUserInterfaceItemIdentifier("TimelinePaneCell") + + private let icon = NSImageView() + private let label = NSTextField(labelWithString: "") + private let folder = NSTextField(labelWithString: "") + + init() { + super.init(frame: .zero) + identifier = Self.identifier + icon.image = NSImage(systemSymbolName: "terminal", accessibilityDescription: nil) + icon.symbolConfiguration = .init(pointSize: 10, weight: .medium) + icon.contentTintColor = .secondaryLabelColor + label.font = .systemFont(ofSize: 11, weight: .semibold) + label.textColor = .labelColor + label.lineBreakMode = .byTruncatingMiddle + folder.font = .systemFont(ofSize: 11) + folder.textColor = .secondaryLabelColor + folder.lineBreakMode = .byTruncatingHead + for view in [icon, label, folder] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + // The folder gives way before where the pane is. + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + folder.setContentCompressionResistancePriority(.defaultLow - 1, for: .horizontal) + label.setContentHuggingPriority(.required, for: .horizontal) + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), + icon.centerYAnchor.constraint(equalTo: centerYAnchor, constant: 3), + label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 5), + label.centerYAnchor.constraint(equalTo: icon.centerYAnchor), + folder.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 6), + folder.firstBaselineAnchor.constraint(equalTo: label.firstBaselineAnchor), + folder.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), + label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + func show(_ title: String, directory: String) { + label.stringValue = title + folder.stringValue = directory + toolTip = "\(title)\n\(directory)" + setAccessibilityLabel("\(title), \(directory)") + } + + /// What the heading shows, for tests. + var shown: (title: String, directory: String) { (label.stringValue, folder.stringValue) } +} + /// A thin line filled to a fraction, on a faint track of the same color. final class ProgressLine: NSView { private var fraction = 0.0 @@ -937,7 +1184,8 @@ final class TimelineCell: NSTableCellView { fatalError("init(coder:) is not supported") } - func show(_ entry: TimelineEntry, query: String, inScrollback: Bool, theme: CodecTheme, codeFont: NSFont) { + func show(_ entry: TimelineEntry, matching expression: NSRegularExpression?, inScrollback: Bool, theme: CodecTheme, + codeFont: NSFont) { command.attributedStringValue = ShellHighlighting.attributed(entry.command, theme: theme, font: codeFont, size: 12) let calendar = Calendar.current time.stringValue = calendar.isDateInToday(entry.started) @@ -977,8 +1225,11 @@ final class TimelineCell: NSTableCellView { var detailText: String var detailColor = NSColor.secondaryLabelColor - if let match = entry.output?.firstLine(matching: query) { - detailText = match.line + let match = expression.flatMap { entry.output?.firstMatch(of: $0) } + // The line that matched, from a little before the match; the lines around it on hover. + detail.toolTip = match.map { [$0.before, $0.line, $0.after].compactMap { $0 }.joined(separator: "\n") } + if let match { + detailText = match.excerpt() detailColor = .labelColor } else if entry.isRunning, let usualText, !entry.fullScreen { detailText = usualText.text @@ -994,7 +1245,7 @@ final class TimelineCell: NSTableCellView { detailText = "No output" detailColor = .tertiaryLabelColor } - if case .failed(let code) = entry.status, code >= 0, entry.output?.firstLine(matching: query) == nil { + if case .failed(let code) = entry.status, code >= 0, match == nil { detailText = "Exit \(code) · " + detailText } detail.stringValue = detailText diff --git a/Sources/CodecUI/MainMenu.swift b/Sources/CodecUI/MainMenu.swift index f0015a2..0803b97 100644 --- a/Sources/CodecUI/MainMenu.swift +++ b/Sources/CodecUI/MainMenu.swift @@ -109,6 +109,8 @@ enum MainMenu { findMenu.addItem(item("Find Next", findAction, "g", tag: Int(NSFindPanelAction.next.rawValue))) findMenu.addItem(item("Find Previous", findAction, "g", [.command, .shift], tag: Int(NSFindPanelAction.previous.rawValue))) findMenu.addItem(item("Use Selection for Find", findAction, "e", tag: Int(NSFindPanelAction.setFindString.rawValue))) + findMenu.addItem(.separator()) + findMenu.addItem(item("Find in All Panes…", #selector(W.findInAllPanes(_:)), "f", [.command, .shift])) find.submenu = findMenu menu.addItem(find) menu.addItem(.separator()) diff --git a/Sources/CodecUI/Pane.swift b/Sources/CodecUI/Pane.swift index 16d73ba..8adc902 100644 --- a/Sources/CodecUI/Pane.swift +++ b/Sources/CodecUI/Pane.swift @@ -21,6 +21,8 @@ protocol PaneHost: AnyObject { func explainFailure(of entry: TimelineEntry, in pane: Pane) /// ⌘-click on the name of a file in the terminal: show it in the editor, or a folder in Finder. func openFile(_ url: URL, from pane: Pane) + /// Find in All Panes, from the terminal's context menu: the Commands sidebar, searching every pane. + func findInAllPanes(from pane: Pane) } /// One shell in one pane: owns the process, the terminal view and the pane's `Session`. @@ -923,6 +925,9 @@ final class Pane: LocalProcessTerminalViewDelegate { /// as they were: nil once they've scrolled out of it, the buffer was cleared, or a full-screen program /// has the screen. func rows(of entry: TimelineEntry) -> Range? { + // A pane in a tab that isn't selected has no window to measure its rows in: that says nothing + // about whether the command is still there. + guard terminal.window != nil else { return nil } relocateBlocksIfNeeded() guard let entry = timeline.value.entry(id: entry.id) else { return nil } if let rows = rowsAsRecorded(of: entry) { return rows } @@ -1108,19 +1113,20 @@ final class Pane: LocalProcessTerminalViewDelegate { return block.output.. Bool { + func reveal(_ entry: TimelineEntry, matching expression: NSRegularExpression? = nil) -> Int? { // Looked up again: the rows may have moved since the caller got its copy. guard let rows = rows(of: entry), var grid = TerminalGrid(terminal: terminal), - let entry = timeline.value.entry(id: entry.id), let block = entry.block else { return false } + let entry = timeline.value.entry(id: entry.id), let block = entry.block else { return nil } terminal.selectNone() var target = block.output - 1 - if !query.isEmpty, block.output < rows.upperBound, + if let expression, block.output < rows.upperBound, let snapshot = terminal.terminalContentSnapshot(region: .history(maximumScrollbackRows: CodecTerminalView.scrollback)) { let output = snapshot.rows.filter { (block.output..= row } ?? nearestToScreen() + } else if let previous, !matches.isEmpty { let start = previous.start current = revealing ? matches.firstIndex { ($0.runs[0].row, $0.runs[0].columns.lowerBound) >= (start.row, start.columns.lowerBound) } @@ -319,9 +332,7 @@ final class FindBar: NSView, NSSearchFieldDelegate { private let glass = NSGlassEffectView() private let field = NSSearchField() private let count = NSTextField(labelWithString: "") - private let caseButton = FindBar.toggle("Aa", help: "Match Case") - private let wordButton = FindBar.toggle("ab", help: "Whole Words") - private let regexButton = FindBar.toggle(".*", help: "Regular Expression") + let toggles = FindOptionToggles() private let previousButton = FindBar.button("chevron.up", help: "Previous Match (⇧⌘G)") private let nextButton = FindBar.button("chevron.down", help: "Next Match (⌘G)") private let closeButton = FindBar.button("xmark", help: "Close (Esc)") @@ -335,8 +346,8 @@ final class FindBar: NSView, NSSearchFieldDelegate { } var options: FindOptions { - FindOptions(caseSensitive: caseButton.state == .on, regex: regexButton.state == .on, - wholeWord: wordButton.state == .on) + get { toggles.options } + set { toggles.options = newValue } } /// Whether the keyboard is in the bar's field. @@ -357,10 +368,7 @@ final class FindBar: NSView, NSSearchFieldDelegate { count.font = .monospacedDigitSystemFont(ofSize: 11, weight: .medium) count.alignment = .right count.lineBreakMode = .byClipping - for button in [caseButton, wordButton, regexButton] { - button.target = self - button.action = #selector(optionChanged) - } + toggles.onChange = { [weak self] in self?.onQueryChanged?() } previousButton.target = self previousButton.action = #selector(previous) nextButton.target = self @@ -368,8 +376,7 @@ final class FindBar: NSView, NSSearchFieldDelegate { closeButton.target = self closeButton.action = #selector(close) - let stack = NSStackView(views: [field, count, caseButton, wordButton, regexButton, - separator(), previousButton, nextButton, closeButton]) + let stack = NSStackView(views: [field, count] + toggles.buttons + [separator(), previousButton, nextButton, closeButton]) stack.orientation = .horizontal stack.alignment = .centerY stack.spacing = 4 @@ -383,10 +390,7 @@ final class FindBar: NSView, NSSearchFieldDelegate { NSLayoutConstraint.activate(glass.constraintsFilling(self) + [ field.widthAnchor.constraint(equalToConstant: 180), count.widthAnchor.constraint(equalToConstant: 62), - ] + [caseButton, wordButton, regexButton].flatMap { [ - $0.widthAnchor.constraint(equalToConstant: 24), $0.heightAnchor.constraint(equalToConstant: 20), - ] }) - styleToggles() + ] + toggles.sizeConstraints) } @available(*, unavailable) @@ -399,22 +403,7 @@ final class FindBar: NSView, NSSearchFieldDelegate { alertColor = theme.alert accentColor = theme.accent count.textColor = count.textColor == alertColor ? alertColor : countColor - styleToggles() - } - - /// An option that's on shows in the accent, on a tint of it; one that's off is dim. - private func styleToggles() { - for button in [caseButton, wordButton, regexButton] { - let on = button.state == .on - button.attributedTitle = NSAttributedString(string: button.title, attributes: [ - .font: NSFont.monospacedSystemFont(ofSize: 11, weight: on ? .bold : .medium), - .foregroundColor: on ? accentColor : countColor, - .underlineStyle: button === wordButton ? NSUnderlineStyle.single.rawValue : 0, - ]) - button.layer?.backgroundColor = on ? accentColor.withAlphaComponent(0.2).cgColor : nil - button.layer?.borderColor = on ? accentColor.withAlphaComponent(0.6).cgColor : nil - button.layer?.borderWidth = on ? 1 : 0 - } + toggles.apply(accent: accentColor, dim: countColor) } func focus() { @@ -472,10 +461,6 @@ final class FindBar: NSView, NSSearchFieldDelegate { return true } - @objc private func optionChanged() { - styleToggles() - onQueryChanged?() - } @objc private func next() { onNext?() } @objc private func previous() { onPrevious?() } @objc private func close() { onClose?() } @@ -487,17 +472,6 @@ final class FindBar: NSView, NSSearchFieldDelegate { return line } - private static func toggle(_ title: String, help: String) -> NSButton { - let button = NSButton(title: title, target: nil, action: nil) - button.setButtonType(.toggle) - button.isBordered = false - button.wantsLayer = true - button.layer?.cornerRadius = 5 - button.toolTip = help - button.setAccessibilityLabel(help) - return button - } - private static func button(_ symbol: String, help: String) -> NSButton { let button = NSButton(image: NSImage(systemSymbolName: symbol, accessibilityDescription: help)!, target: nil, action: nil) @@ -510,3 +484,80 @@ final class FindBar: NSView, NSSearchFieldDelegate { return button } } + +/// Find's match options as toggles, Aa, ab and .*, in the find bar and the Commands sidebar's filter. +@MainActor +final class FindOptionToggles: NSObject { + let caseButton = FindOptionToggles.toggle("Aa", help: "Match Case") + let wordButton = FindOptionToggles.toggle("ab", help: "Whole Words") + let regexButton = FindOptionToggles.toggle(".*", help: "Regular Expression") + /// An option was turned on or off by the user. + var onChange: (() -> Void)? + private var accent: NSColor = .controlAccentColor + private var dim: NSColor = .secondaryLabelColor + + var buttons: [NSButton] { [caseButton, wordButton, regexButton] } + + override init() { + super.init() + for button in buttons { + button.target = self + button.action = #selector(changed) + } + style() + } + + var options: FindOptions { + get { + FindOptions(caseSensitive: caseButton.state == .on, regex: regexButton.state == .on, + wholeWord: wordButton.state == .on) + } + set { + caseButton.state = newValue.caseSensitive ? .on : .off + wordButton.state = newValue.wholeWord ? .on : .off + regexButton.state = newValue.regex ? .on : .off + style() + } + } + + var sizeConstraints: [NSLayoutConstraint] { + buttons.flatMap { [$0.widthAnchor.constraint(equalToConstant: 24), $0.heightAnchor.constraint(equalToConstant: 20)] } + } + + func apply(accent: NSColor, dim: NSColor) { + self.accent = accent + self.dim = dim + style() + } + + @objc private func changed() { + style() + onChange?() + } + + /// An option that's on shows in the accent, on a tint of it; one that's off is dim. + private func style() { + for button in buttons { + let on = button.state == .on + button.attributedTitle = NSAttributedString(string: button.title, attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 11, weight: on ? .bold : .medium), + .foregroundColor: on ? accent : dim, + .underlineStyle: button === wordButton ? NSUnderlineStyle.single.rawValue : 0, + ]) + button.layer?.backgroundColor = on ? accent.withAlphaComponent(0.2).cgColor : nil + button.layer?.borderColor = on ? accent.withAlphaComponent(0.6).cgColor : nil + button.layer?.borderWidth = on ? 1 : 0 + } + } + + private static func toggle(_ title: String, help: String) -> NSButton { + let button = NSButton(title: title, target: nil, action: nil) + button.setButtonType(.toggle) + button.isBordered = false + button.wantsLayer = true + button.layer?.cornerRadius = 5 + button.toolTip = help + button.setAccessibilityLabel(help) + return button + } +} diff --git a/Sources/CodecUI/TerminalWindowController.swift b/Sources/CodecUI/TerminalWindowController.swift index 9f8506f..144029d 100644 --- a/Sources/CodecUI/TerminalWindowController.swift +++ b/Sources/CodecUI/TerminalWindowController.swift @@ -161,6 +161,10 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe timelineSidebar = CommandTimelineSidebar(theme: preferences.theme, codeFont: preferences.font()) timelineSidebar.onResize = { [weak self] width in self?.resizeTimeline(to: width) } timelineSidebar.onSaveToIntel = { [weak self] entry, pane in self?.saveToIntel(entry, from: pane) } + timelineSidebar.places = { [weak self] in self?.panePlaces() ?? [] } + timelineSidebar.onGoToCommand = { [weak self] entry, pane, filter in + self?.goToCommand(entry, in: pane, filter: filter) ?? false + } timelineSidebar.explainer = { [weak self] in guard let self, self.preferences.explainFailures else { return nil } return self.environment.explainer @@ -263,6 +267,7 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe NSLayoutConstraint.activate(root.constraintsFilling(paneContainer)) installedTab = tab installedLayout = tab.layout + NotificationCenter.default.post(name: CommandTimelineSidebar.panesDidChange, object: self) } updatePaneFocus() } @@ -519,6 +524,59 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe } } + func findInAllPanes(from pane: Pane) { + findInAllPanes(nil) + } + + /// ⇧⌘F: the Commands sidebar, listing every pane's commands, with the keyboard in its filter. + @objc func findInAllPanes(_ sender: Any?) { + if !model.timelineVisible { setTimelineVisible(true) } + window?.layoutIfNeeded() + timelineSidebar.findInAllPanes() + } + + /// Every window's panes, in order, with where each one is: the window when there are several, the + /// tab, and the pane's place in it when it's split. + func panePlaces() -> [CommandTimelineSidebar.PanePlace] { + let windows = allWindows + return windows.enumerated().flatMap { index, controller in + controller.model.tabs.flatMap { tab in + zip(tab.layout.leaves, tab.layout.placeNames).map { pane, place in + let window = windows.count > 1 ? "Window \(index + 1)" : nil + return CommandTimelineSidebar.PanePlace( + pane: pane, title: [window, tab.title, place].compactMap { $0 }.joined(separator: " › ")) + } + } + } + } + + /// Every open window, this one included. + private var allWindows: [TerminalWindowController] { + let windows = environment.coordinator?.terminalWindows ?? [] + return windows.contains { $0 === self } ? windows : [self] + windows + } + + /// Goes to a command found in All Panes: brings its window to the front, selects its tab and pane, + /// scrolls to the line that matched and opens Find there with the same query, so ⌘G goes on to the + /// next match. False when the command can't be gone to, as it's no longer in the scrollback. + func goToCommand(_ entry: TimelineEntry, in pane: Pane, filter: TimelineFilter) -> Bool { + guard entry.block != nil, !entry.fullScreen, !pane.isShutDown, + let controller = allWindows.first(where: { $0.model.tabs.contains { $0.layout.contains(pane) } }) + else { return false } + return controller.show(entry, in: pane, filter: filter) + } + + private func show(_ entry: TimelineEntry, in pane: Pane, filter: TimelineFilter) -> Bool { + guard let tab = model.tabs.first(where: { $0.layout.contains(pane) }) else { return false } + if tab !== model.selected { select(tab) } + tab.focus(pane) + window?.makeKeyAndOrderFront(nil) + window?.layoutIfNeeded() + guard let row = pane.reveal(entry, matching: filter.expression) else { return false } + if !filter.text.isEmpty { pane.view.finder.open(query: filter.text, options: filter.options, from: row) } + return true + } + /// Keeps the tree on the focused pane's directory. private func followFocusedDirectory() { guard let pane = focusedPane, pane !== followedPane else { return } @@ -827,6 +885,7 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe installSelectedTab() } environment.coordinator?.attentionDidChange() + NotificationCenter.default.post(name: CommandTimelineSidebar.panesDidChange, object: self) } private func removeTab(_ tab: TerminalTab) { @@ -965,12 +1024,12 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe tab.focus(pane) if !model.timelineVisible { setTimelineVisible(true) } window?.layoutIfNeeded() - let row = timelineSidebar.rows.firstIndex { $0.entry?.id == entry.id } + let row = timelineSidebar.row(of: entry, in: pane) if let row { timelineSidebar.table.selectRowIndexes([row], byExtendingSelection: false) timelineSidebar.table.scrollRowToVisible(row) } - timelineSidebar.showOutput(of: entry, row: row ?? -1, explain: true) + timelineSidebar.showOutput(of: entry, in: pane, row: row ?? -1, explain: true) } /// ⌘Y: the history search, for the focused pane. @@ -1403,6 +1462,9 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe item.tag = Int(NSFindPanelAction.showFindPanel.rawValue) self?.focusedPane?.terminal.performFindPanelAction(item) }, + .init(id: "find-all-panes", title: "Find in All Panes…", shortcut: "⇧⌘F", systemImage: "rectangle.and.text.magnifyingglass") { [weak self] in + self?.findInAllPanes(nil) + }, .init(id: "bigger", title: "Increase Font Size", shortcut: "⌘+", systemImage: "textformat.size.larger") { [weak self] in self?.biggerFont(nil) }, .init(id: "smaller", title: "Decrease Font Size", shortcut: "⌘−", systemImage: "textformat.size.smaller") { [weak self] in self?.smallerFont(nil) }, .init(id: "reset-font", title: "Reset Font Size", shortcut: "⌘0", systemImage: "textformat.size") { [weak self] in self?.resetFontSize(nil) }, @@ -1513,5 +1575,6 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe cancellables.removeAll() tabObservers.removeAll() onClose?(self) + NotificationCenter.default.post(name: CommandTimelineSidebar.panesDidChange, object: self) } } diff --git a/Tests/CodecCoreTests/CommandTimelineTests.swift b/Tests/CodecCoreTests/CommandTimelineTests.swift index d389c7a..cddbb40 100644 --- a/Tests/CodecCoreTests/CommandTimelineTests.swift +++ b/Tests/CodecCoreTests/CommandTimelineTests.swift @@ -50,6 +50,59 @@ struct CommandTimelineTests { #expect(timeline.entries(matching: "branch", failedOnly: true).isEmpty) } + /// The sidebar's filter matches as Find does: Aa, ab and .* apply, and a pattern that isn't valid + /// matches nothing. + @Test func filtersWithFindsOptions() { + var timeline = CommandTimeline() + timeline.begin(command: "npm test", directory: "/", started: start, block: nil) + timeline.finish(exitCode: 1, duration: 2, output: CapturedOutput(lines: ["Error: connect ECONNREFUSED 127.0.0.1:5432"])) + timeline.begin(command: "curl localhost:4000", directory: "/", started: start, block: nil) + timeline.finish(exitCode: 7, duration: 0, output: CapturedOutput(lines: ["curl: (7) Connection refused"])) + timeline.begin(command: "echo refusedly", directory: "/", started: start, block: nil) + timeline.finish(exitCode: 0, duration: 0, output: CapturedOutput(lines: ["refusedly"])) + + func commands(_ query: String, _ options: FindOptions = FindOptions(), failedOnly: Bool = false) -> [String] { + timeline.entries(matching: TimelineFilter(query: query, options: options, failedOnly: failedOnly)).map(\.command) + } + #expect(commands("refused") == ["npm test", "curl localhost:4000", "echo refusedly"]) + #expect(commands("refused", failedOnly: true) == ["npm test", "curl localhost:4000"]) + #expect(commands("Refused", FindOptions(caseSensitive: true)).isEmpty) + #expect(commands("refused", FindOptions(wholeWord: true)) == ["curl localhost:4000"]) + #expect(commands("econn|:4000", FindOptions(regex: true)) == ["npm test", "curl localhost:4000"]) + #expect(commands("econn|:4000").isEmpty) + #expect(commands(" ").count == 3) + + let invalid = TimelineFilter(query: "(", options: FindOptions(regex: true)) + #expect(invalid.isInvalid) + #expect(timeline.entries(matching: invalid).isEmpty) + #expect(!TimelineFilter(query: "(").isInvalid) + #expect(!TimelineFilter(query: "", options: FindOptions(regex: true)).isInvalid) + } + + @Test func findsTheMatchWithTheLinesAroundIt() throws { + let output = CapturedOutput(lines: [ + "> api@1.0.0 test", "", " at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1555:16) ECONNREFUSED here", + " done", "", + ]) + let expression = try #require(TextSearch.expression(for: "econnrefused", options: FindOptions())) + let match = try #require(output.firstMatch(of: expression)) + #expect(match.index == 2) + #expect(match.line == "at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1555:16) ECONNREFUSED here") + #expect(Array(match.line)[match.range].map(String.init).joined() == "ECONNREFUSED") + #expect(match.before == "> api@1.0.0 test") + #expect(match.after == "done") + // A long line starts a little before the match, so a narrow list still shows it. + #expect(match.excerpt(leading: 6) == "…5:16) ECONNREFUSED here") + #expect(match.excerpt(leading: 100) == match.line) + + func firstMatch(_ query: String) -> OutputMatch? { + TextSearch.expression(for: query, options: FindOptions()).flatMap(output.firstMatch) + } + let first = try #require(firstMatch("api")) + #expect(first.index == 0 && first.before == nil && first.range == 2..<5) + #expect(firstMatch("nowhere") == nil) + } + @Test func forgetsWhereCommandsWereOnScreen() { var timeline = CommandTimeline() let first = timeline.begin(command: "a", directory: "/", started: start, block: .init(prompt: 0, output: 1)) diff --git a/Tests/CodecCoreTests/SplitLayoutTests.swift b/Tests/CodecCoreTests/SplitLayoutTests.swift index 7ac7267..d443488 100644 --- a/Tests/CodecCoreTests/SplitLayoutTests.swift +++ b/Tests/CodecCoreTests/SplitLayoutTests.swift @@ -25,6 +25,22 @@ struct SplitLayoutTests { } } + /// What the Commands sidebar calls each pane when it lists every pane's commands. + @Test func namesEachLeafByWhereItIs() { + #expect(SplitLayout.leaf(a).placeNames == [nil]) + #expect(SplitLayout.split(.horizontal, [.leaf(a), .leaf(b)]).placeNames == ["left", "right"]) + #expect(SplitLayout.split(.vertical, [.leaf(a), .leaf(b), .leaf(c)]).placeNames == ["top", "middle", "bottom"]) + #expect(SplitLayout.split(.horizontal, [.leaf(a), .split(.vertical, [.leaf(b), .leaf(c)])]).placeNames + == ["left", "top right", "bottom right"]) + #expect(SplitLayout.split(.vertical, [.split(.horizontal, [.leaf(a), .leaf(b)]), .leaf(c)]).placeNames + == ["top left", "top right", "bottom"]) + // Too many, or nested too deep, to say in a few words: numbered. + #expect(SplitLayout.split(.horizontal, [.leaf(a), .leaf(b), .leaf(c), .leaf(d)]).placeNames + == ["pane 1", "pane 2", "pane 3", "pane 4"]) + let deep = SplitLayout.split(.horizontal, [.leaf(a), .split(.vertical, [.split(.horizontal, [.leaf(b), .leaf(c)]), .leaf(d)])]) + #expect(deep.placeNames == ["pane 1", "pane 2", "pane 3", "pane 4"]) + } + @Test func singleLeafContainsOnlyItself() { let layout = SplitLayout.leaf(a) #expect(names(layout) == ["a"]) diff --git a/Tests/CodecUITests/ModelTests.swift b/Tests/CodecUITests/ModelTests.swift index f58ff51..6090411 100644 --- a/Tests/CodecUITests/ModelTests.swift +++ b/Tests/CodecUITests/ModelTests.swift @@ -176,6 +176,16 @@ struct MainMenuTests { } } + @Test func findsInAllPanesWithShiftCommandF() throws { + _ = NSApplication.shared + let menu = MainMenu.build() + let find = try #require(item(menu, path: ["Edit", "Find", "Find in All Panes…"])) + #expect(find.keyEquivalent == "f" && find.keyEquivalentModifierMask == [.command, .shift]) + #expect(find.action == #selector(TerminalWindowController.findInAllPanes(_:))) + func all(_ menu: NSMenu) -> [NSMenuItem] { menu.items.flatMap { [$0] + ($0.submenu.map(all) ?? []) } } + #expect(all(menu).filter { $0.keyEquivalent == "f" && $0.keyEquivalentModifierMask == [.command, .shift] }.count == 1) + } + @Test func splitsTowardAnArrowWithControlCommand() throws { _ = NSApplication.shared let menu = MainMenu.build() diff --git a/Tests/CodecUITests/PaneTests.swift b/Tests/CodecUITests/PaneTests.swift index 74d208e..b69550b 100644 --- a/Tests/CodecUITests/PaneTests.swift +++ b/Tests/CodecUITests/PaneTests.swift @@ -254,7 +254,7 @@ struct PaneContextMenuTests { defer { pane.shutDown() } #expect(pane.contextMenu().itemTitles == [ "Cut", "Copy", "Paste", "Select All", "Copy Output", "Select Output", "Split Left", "Split Right", "Split Up", "Split Down", - "Find…", "Clear Buffer", + "Find…", "Find in All Panes…", "Clear Buffer", "Restart Shell", "Open in Finder", "Copy Path", "Switch Branch…", "Close Pane", ]) } diff --git a/Tests/CodecUITests/TestSupport.swift b/Tests/CodecUITests/TestSupport.swift index a54609b..dc31f4d 100644 --- a/Tests/CodecUITests/TestSupport.swift +++ b/Tests/CodecUITests/TestSupport.swift @@ -39,6 +39,7 @@ final class FakeCoordinator: AppCoordinating { func newWindow() { newWindows += 1 } func showSettings() { settingsShown += 1 } func attentionDidChange() { attentionChanges += 1 } + var terminalWindows: [TerminalWindowController] = [] } @MainActor @@ -59,6 +60,8 @@ final class FakeHost: PaneHost { func explainFailure(of entry: TimelineEntry, in pane: Pane) { explained.append(entry) } private(set) var openedFiles: [URL] = [] func openFile(_ url: URL, from pane: Pane) { openedFiles.append(url) } + private(set) var findsInAllPanes = 0 + func findInAllPanes(from pane: Pane) { findsInAllPanes += 1 } } /// Never reports anything, so tests don't depend on the machine's load. diff --git a/Tests/CodecUITests/TimelineTests.swift b/Tests/CodecUITests/TimelineTests.swift index 96aac1a..d3f7219 100644 --- a/Tests/CodecUITests/TimelineTests.swift +++ b/Tests/CodecUITests/TimelineTests.swift @@ -89,7 +89,7 @@ struct TimelineTests { let before = try #require(TerminalGrid(terminal: pane.terminal)) #expect(before.viewportTop > block.prompt) - #expect(pane.reveal(first)) + #expect(pane.reveal(first) != nil) let after = try #require(TerminalGrid(terminal: pane.terminal)) #expect((after.viewportTop..<(after.viewportTop + after.rows.count)).contains(block.prompt)) #expect(pane.view.commandMark.isShowing) @@ -97,8 +97,9 @@ struct TimelineTests { // With a query, it goes to the line of the output that holds it. let seq = pane.timeline.entries[1] - #expect(pane.reveal(seq, matching: "77")) + let row = try #require(pane.reveal(seq, matching: TextSearch.expression(for: "77", options: FindOptions()))) let atMatch = try #require(TerminalGrid(terminal: pane.terminal)) + #expect(atMatch.rows.first { $0.absoluteRow == row }?.text.trimmingCharacters(in: .whitespaces) == "77") #expect(atMatch.rows.contains { $0.text.trimmingCharacters(in: .whitespaces) == "77" }) } @@ -123,7 +124,7 @@ struct TimelineTests { #expect(pane.text(of: rows.dropFirst(rows.count - 1)) == "second") #expect(pane.output(of: pane.timeline.entries[0])?.hasPrefix("a-fairly-long") == true) #expect(pane.lastOutputRange.flatMap(pane.text(of:)) == "second") - #expect(pane.reveal(pane.timeline.entries[0])) + #expect(pane.reveal(pane.timeline.entries[0]) != nil) // Running the same command again after another resize doesn't take the earlier one's place. window.setContentSize(NSSize(width: 500, height: 300)) @@ -257,7 +258,7 @@ struct TimelineTests { #expect(await run("echo after", in: pane)) let cleared = try #require(pane.timeline.entries.first) #expect(pane.rows(of: cleared) == nil) - #expect(!pane.reveal(cleared)) + #expect(pane.reveal(cleared) == nil) #expect(pane.output(of: cleared) == "kept\nfor later") #expect(pane.rows(of: pane.timeline.entries[1]) != nil) } @@ -343,6 +344,103 @@ struct TimelineTests { #expect(controller.paletteCommands().contains { $0.id == "commands" && $0.title == "Show Commands Sidebar" }) } + /// ⇧⌘F: every pane's commands, in every tab, under the pane they ran in, filtered as Find matches; + /// going to one selects its tab and pane and opens Find there. + @Test func searchesEveryPanesOutput() async throws { + let env = try TestEnvironment(shell: "/bin/zsh", integration: true) + defer { env.tearDown() } + try useCleanZsh(env) + defer { unsetenv("ZDOTDIR") } + let controller = TerminalWindowController(directory: env.directory.path, environment: env.environment) + controller.window?.setContentSize(NSSize(width: 1100, height: 600)) + controller.window?.layoutIfNeeded() + var others: [TerminalWindowController] = [] + defer { + for window in [controller] + others { + window.model.tabs.flatMap(\.panes).forEach { $0.shutDown() } + window.window?.close() + } + } + // The commands don't hold what's searched for; their output does. + let left = try #require(controller.model.selected?.focused) + #expect(await waitUntil { left.isAtCommandLine }) + #expect(await run("echo 'Error: connect ECONN''REFUSED 127.0.0.1:5432'", in: left)) + controller.splitRight(nil) + let right = try #require(controller.model.selected?.focused) + #expect(await waitUntil { right.isAtCommandLine }) + #expect(await run("echo all good", in: right)) + let firstTab = try #require(controller.model.selected) + controller.newTab(nil) + let other = try #require(controller.model.selected?.focused) + #expect(await waitUntil { other.isAtCommandLine }) + #expect(await run("echo 'bind: econn''refused too'", in: other)) + let secondTab = try #require(controller.model.selected) + + controller.findInAllPanes(nil) + let sidebar = controller.timelineSidebar! + #expect(controller.model.timelineVisible) + #expect(sidebar.scope == .all) + #expect(sidebar.scopeControl.selectedSegment == 1) + #expect((controller.window?.firstResponder as? NSText)?.delegate === sidebar.searchField) + + sidebar.searchField.stringValue = "ECONNREFUSED" + sidebar.reload() + let directory = left.session.directory + #expect(sidebar.rows == [ + .pane("\(firstTab.title) › left", directory: directory), + .entry(left.timeline.entries[0]), + .pane(secondTab.title, directory: directory), + .entry(other.timeline.entries[0]), + ]) + let cell = try #require(sidebar.table.view(atColumn: 0, row: 1, makeIfNecessary: true) as? TimelineCell) + #expect(cell.shown.detail == "Error: connect ECONNREFUSED 127.0.0.1:5432") + + // Find's options apply. + sidebar.toggles.options = FindOptions(caseSensitive: true) + sidebar.reload() + #expect(sidebar.rows.compactMap(\.entry).map(\.command) == [left.timeline.entries[0].command]) + sidebar.toggles.options = FindOptions(regex: true) + sidebar.searchField.stringValue = "good|too$" + sidebar.reload() + #expect(sidebar.rows.compactMap(\.entry).map(\.command) == ["echo all good", "echo 'bind: econn''refused too'"]) + #expect(sidebar.rows.first == .pane("\(firstTab.title) › right", directory: directory)) + sidebar.searchField.stringValue = "(" + sidebar.reload() + #expect(sidebar.rows.isEmpty) + sidebar.toggles.options = FindOptions() + sidebar.searchField.stringValue = "econnrefused" + sidebar.reload() + + // Going to a command in the other tab selects it, and opens Find there on the line that matched. + let hit = try #require(sidebar.row(of: left.timeline.entries[0], in: left)) + sidebar.table.selectRowIndexes([hit], byExtendingSelection: false) + sidebar.goToSelected(focusTerminal: true) + #expect(controller.model.selected === firstTab) + #expect(firstTab.focused === left) + let finder = left.view.finder + #expect(finder.isOpen) + #expect(finder.bar.query == "econnrefused") + let current = try #require(finder.current) + let block = try #require(left.timeline.entries[0].block) + #expect(finder.matches[current].row == block.output) + #expect(sidebar.scope == .all) + #expect(sidebar.rows.compactMap(\.entry).count == 2) + #expect(sidebar.pane === left) + + // Back to this pane's commands. + sidebar.setScope(.pane) + #expect(sidebar.rows.compactMap(\.entry).map(\.command) == [left.timeline.entries[0].command]) + + // With several windows, each pane says which window it's in. + let second = TerminalWindowController(directory: env.directory.path, environment: env.environment) + others.append(second) + env.coordinator.terminalWindows = [controller, second] + let titles = controller.panePlaces().map(\.title) + #expect(titles.count == 4) + #expect(titles.prefix(3).allSatisfy { $0.hasPrefix("Window 1 › ") }) + #expect(titles.last?.hasPrefix("Window 2 › ") == true) + } + /// Regression: after a resize, zsh printed a prompt of several lines again below the old one's /// upper lines, leaving them twice on screen. @Test func aResizeRedrawsATallPromptInPlace() async throws { diff --git a/Tests/CodecUITests/UsualDurationUITests.swift b/Tests/CodecUITests/UsualDurationUITests.swift index dfef26a..4142e9f 100644 --- a/Tests/CodecUITests/UsualDurationUITests.swift +++ b/Tests/CodecUITests/UsualDurationUITests.swift @@ -21,30 +21,30 @@ struct UsualDurationUITests { let theme = CodecTheme.codec let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) - cell.show(entry("make test", started: Date().addingTimeInterval(-42), usual: 96), query: "", inScrollback: true, + cell.show(entry("make test", started: Date().addingTimeInterval(-42), usual: 96), matching: nil, inScrollback: true, theme: theme, codeFont: font) #expect(cell.shown.detail == "54 s left · usually 1m 36s") #expect(abs((cell.shown.progress ?? 0) - 42.0 / 96) < 0.02) - cell.show(entry("make test", started: Date().addingTimeInterval(-108), usual: 96), query: "", inScrollback: true, + cell.show(entry("make test", started: Date().addingTimeInterval(-108), usual: 96), matching: nil, inScrollback: true, theme: theme, codeFont: font) #expect(cell.shown.detail == "12 s over its usual 1m 36s") #expect(cell.shown.progress == 1 && cell.shown.durationColor == theme.warning) // Done: slower ones say by how much; the rest look as they always did. - cell.show(entry("npm run lint", started: Date(), usual: 80, duration: 130, exitCode: 0), query: "", inScrollback: true, + cell.show(entry("npm run lint", started: Date(), usual: 80, duration: 130, exitCode: 0), matching: nil, inScrollback: true, theme: theme, codeFont: font) #expect(cell.shown.duration == "2m 10s · 1.6×" && cell.shown.durationColor == theme.warning && cell.shown.progress == nil) #expect(cell.accessibilityLabel()?.contains("1.6× its usual 1m 20s") == true) - cell.show(entry("swift build", started: Date(), usual: 48, duration: 47, exitCode: 0), query: "", inScrollback: true, + cell.show(entry("swift build", started: Date(), usual: 48, duration: 47, exitCode: 0), matching: nil, inScrollback: true, theme: theme, codeFont: font) #expect(cell.shown.duration == "47.0 s" && cell.shown.durationColor == .secondaryLabelColor) // A failed run isn't compared: it ended early. - cell.show(entry("swift build", started: Date(), usual: 48, duration: 300, exitCode: 1), query: "", inScrollback: true, + cell.show(entry("swift build", started: Date(), usual: 48, duration: 300, exitCode: 1), matching: nil, inScrollback: true, theme: theme, codeFont: font) #expect(cell.shown.duration == "5m 00s") // Without a usual time, a running command reads as before. - cell.show(entry("make", started: Date(), usual: nil), query: "", inScrollback: true, theme: theme, codeFont: font) + cell.show(entry("make", started: Date(), usual: nil), matching: nil, inScrollback: true, theme: theme, codeFont: font) #expect(cell.shown.detail == "Running…" && cell.shown.progress == nil) } diff --git a/docs/README.md b/docs/README.md index f7c5605..b9e3d50 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ How to use Codec, a feature at a time. New here? Start with [Getting started](ge | [Getting started](getting-started.md) | Requirements, installing, updates, building from source, shell integration, where Codec keeps things | | [Tabs and panes](tabs-and-panes.md) | Tabs, splits, focus, closing safely, attention and exits | | [The command line](command-line.md) | Editing the line like a text field, selecting text, history as you type, the history menu and search, Tab completion, the lines between commands, command output | -| [Commands sidebar](commands-sidebar.md) | Every command the pane ran, with its output, to go back to | +| [Commands sidebar](commands-sidebar.md) | Every command the pane ran, with its output, to go back to; or every pane's, to search them all at once | | [Intel](intel.md) | Saved scripts with variables and notes, personal or shared with the project, in folders; the file format | | [Files and editor](files-and-editor.md) | The file tree, the editor, Markdown preview | | [Prompt and status bar](prompt-and-status-bar.md) | The prompt builder, the status bar, the branch switcher, Python environments and tool versions, system meters | diff --git a/docs/commands-sidebar.md b/docs/commands-sidebar.md index 1b62092..c029cb9 100644 --- a/docs/commands-sidebar.md +++ b/docs/commands-sidebar.md @@ -4,7 +4,7 @@ You tried a few things, went on for a while, and now you want to see what happened earlier. The Commands sidebar is that pane's story: every command it ran, with how it went and what it printed. -Open it with ⇧⌘L, or the sidebar button at the right end of the tab bar. It follows the focused pane, like the file tree. +Open it with ⇧⌘L, or the sidebar button at the right end of the tab bar. It follows the focused pane, like the file tree; switch it to **All Panes** to see every pane's commands at once (see [Searching every pane](#searching-every-pane)). ## What it shows @@ -58,7 +58,26 @@ Turn it off in Settings › Shell › Offer to explain failed commands. ## Filtering -The filter field searches commands and what they printed. A row shows the line that matched, and going to it scrolls to that line. ⚠ shows failed commands only; the bin clears the list (the commands are still tracked, just hidden). +The filter field searches commands and what they printed. A row shows the line that matched (hover it for the lines around it), and going to it scrolls to that line. **Aa**, **ab** and **.\*** beside the field match case, whole words and regular expressions, as in Find (⌘F). ⚠ shows failed commands only; the bin clears the list (the commands are still tracked, just hidden). + +## Searching every pane + +"Where did I see that error?", with three tabs and six panes: press ⇧⌘F (Edit › Find › Find in All Panes…, or right-click the terminal), or pick **All Panes** at the top of the sidebar. It then lists the commands of every pane in every window, under the pane they ran in: which window (when there are several), which tab, where the pane is in it ("left", "top right") and its folder. Panes that came back when Codec reopened are in it too, with the commands they ran before. + +``` +Window 1 › api › left · ~/code/api + ✗ npm test 14:02 + Error: connect ECONNREFUSED 127.0.0.1:5432 +Window 2 › infra · ~/infra + ✓ docker compose logs db 13:55 + … could not bind to 0.0.0.0:5432: address in use +``` + +The filter, Aa / ab / .\* and ⚠ work as for one pane. What's searched is each command's line and the output Codec kept when it finished, so a pane that a full-screen program (vim, less, htop) has taken over is searched all the same. + +Click a command, or select it and press Return: Codec brings its window to the front, selects its tab and pane, scrolls to the line that matched and marks it, and opens Find there with the same query, so ⌘G goes on to the next match. A command that has left the scrollback opens in the output viewer instead. Arrow keys only move through the list here, since going to a command may switch tabs or windows. Insert Command and Run Again put the command on the focused pane's command line. + +Pick **This Pane** to go back to the focused pane's commands. ## Right-click diff --git a/docs/images/branches.png b/docs/images/branches.png index 1ede23c..c8376aa 100644 Binary files a/docs/images/branches.png and b/docs/images/branches.png differ diff --git a/docs/images/commands.png b/docs/images/commands.png index 6dce0e2..9daae5c 100644 Binary files a/docs/images/commands.png and b/docs/images/commands.png differ diff --git a/docs/images/editor.png b/docs/images/editor.png index 25ea27a..061eb73 100644 Binary files a/docs/images/editor.png and b/docs/images/editor.png differ diff --git a/docs/images/environment-list.png b/docs/images/environment-list.png index 1df758d..2d82d34 100644 Binary files a/docs/images/environment-list.png and b/docs/images/environment-list.png differ diff --git a/docs/images/environment-settings.png b/docs/images/environment-settings.png index 8ad68f0..c50efc3 100644 Binary files a/docs/images/environment-settings.png and b/docs/images/environment-settings.png differ diff --git a/docs/images/environments.png b/docs/images/environments.png index 5ab417c..ad1e2b2 100644 Binary files a/docs/images/environments.png and b/docs/images/environments.png differ diff --git a/docs/images/hero.png b/docs/images/hero.png index 651389b..2f5e0b5 100644 Binary files a/docs/images/hero.png and b/docs/images/hero.png differ diff --git a/docs/images/history.png b/docs/images/history.png index f2d9d40..5e6a947 100644 Binary files a/docs/images/history.png and b/docs/images/history.png differ diff --git a/docs/images/intel-document.png b/docs/images/intel-document.png index 06c0aaf..4c5809b 100644 Binary files a/docs/images/intel-document.png and b/docs/images/intel-document.png differ diff --git a/docs/images/intel.png b/docs/images/intel.png index 64a6237..4f9a1b5 100644 Binary files a/docs/images/intel.png and b/docs/images/intel.png differ diff --git a/docs/images/listing.png b/docs/images/listing.png index 536231b..b4366c7 100644 Binary files a/docs/images/listing.png and b/docs/images/listing.png differ diff --git a/docs/images/open-file.png b/docs/images/open-file.png index cde7f69..fdc8e6d 100644 Binary files a/docs/images/open-file.png and b/docs/images/open-file.png differ diff --git a/docs/images/themes.png b/docs/images/themes.png index 712b8f4..6435897 100644 Binary files a/docs/images/themes.png and b/docs/images/themes.png differ diff --git a/docs/images/usual-duration.png b/docs/images/usual-duration.png index 7e0451b..9ec0683 100644 Binary files a/docs/images/usual-duration.png and b/docs/images/usual-duration.png differ diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 8fd582f..9513d33 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -26,6 +26,7 @@ | ⌘K | Clear buffer | | ⌘F, ⌘G, ⇧⌘G | Find in the scrollback, next match, previous match | | ⌘E | Find the selected text | +| ⇧⌘F | Find in all panes: every pane's commands and output, in every window, in the Commands sidebar | | ⌘+ / ⌘− / ⌘0 | Font size | | ⇧⌘B | Switch branch | | ⌘-click | Open a file or folder named in the output (`ls`, `git status`, `file.swift:42:7`), or a link | @@ -48,6 +49,7 @@ | ⇧⌘E | Show / hide the file tree | | ⇧⌘I | Show / hide Intel | | ⇧⌘L | Show / hide the Commands sidebar | +| ⇧⌘F | Commands sidebar, All Panes, with the keyboard in its filter | | Return | Open the selected item; in the Commands sidebar, go to the command | | Space | Commands sidebar: show the command's output | | → / ← | Intel: open / close a folder |