diff --git a/README.md b/README.md index 413927a..f09a031 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Intel is plain Markdown in folders: yours in `~/.codec/intel`, and your team's i The history search, grouped by day, with folders, durations and failures -↑ at the prompt opens a menu of what you've typed before, filtered as you type. ⌃R or ⌘Y opens the full search: every command with the folder it ran in, how long it took, whether it failed, ranked by how well it matches, then by whether it ran here. As you type, the rest of your last matching command waits dimmed after the cursor; → takes it. +↑ at the prompt walks back through what you've run, as in any shell, with a menu above the prompt showing where you are. ⌃R or ⌘Y opens the full search: every command with the folder it ran in, how long it took, whether it failed, ranked by how well it matches, then by whether it ran here. As you type, the rest of your last matching command waits dimmed after the cursor; → takes it. → [The command line](docs/command-line.md) diff --git a/Sources/CodecCore/CommandHistory.swift b/Sources/CodecCore/CommandHistory.swift index cadcb59..4e73a81 100644 --- a/Sources/CodecCore/CommandHistory.swift +++ b/Sources/CodecCore/CommandHistory.swift @@ -100,27 +100,7 @@ public struct CommandHistory: Equatable, Sendable { /// ran in `directory` come first, then the most recent. public func items(matching query: String, filter: HistoryFilter = .all, directory: String? = nil, limit: Int = 200) -> [HistoryItem] { - var merged: [String: (item: HistoryItem, order: Int, directories: Set)] = [:] - var order = 0 - for entry in entries.reversed() { - if var existing = merged[entry.command] { - existing.item.count += 1 - if let directory = entry.directory { existing.directories.insert(directory) } - // Fill in what the shell's file doesn't know from Codec's own record. - if existing.item.latest.source == .shell, entry.source == .codec { - existing.item.latest.directory = entry.directory - existing.item.latest.exitCode = entry.exitCode - existing.item.latest.duration = entry.duration - existing.item.latest.source = .codec - } - merged[entry.command] = existing - } else { - merged[entry.command] = (HistoryItem(command: entry.command, latest: entry, count: 1, match: nil), order, - entry.directory.map { [$0] } ?? []) - order += 1 - } - } - var candidates = merged.values.filter { candidate in + var candidates = merged().filter { candidate in switch filter { case .all: true case .directory(let path): candidate.directories.contains(path) @@ -164,6 +144,53 @@ public struct CommandHistory: Equatable, Sendable { .map(\.item) } + /// Commands to walk back through with ↑ from what's typed, the way a shell does: those starting + /// with it, newest first, then those containing it, newest first. Ignores case; with nothing + /// typed, every command, newest first. + public func recall(_ typed: String, limit: Int = 200) -> [HistoryItem] { + let candidates = merged().sorted { $0.order < $1.order }.map(\.item) + guard !typed.trimmingCharacters(in: .whitespaces).isEmpty else { return Array(candidates.prefix(limit)) } + let lowered = typed.lowercased() + var starting: [HistoryItem] = [], containing: [HistoryItem] = [] + for var item in candidates { + let lower = item.command.lowercased() + if lower.hasPrefix(lowered) { + item.match = 0.. [(item: HistoryItem, order: Int, directories: Set)] { + var merged: [String: (item: HistoryItem, order: Int, directories: Set)] = [:] + var order = 0 + for entry in entries.reversed() { + if var existing = merged[entry.command] { + existing.item.count += 1 + if let directory = entry.directory { existing.directories.insert(directory) } + // Fill in what the shell's file doesn't know from Codec's own record. + if existing.item.latest.source == .shell, entry.source == .codec { + existing.item.latest.directory = entry.directory + existing.item.latest.exitCode = entry.exitCode + existing.item.latest.duration = entry.duration + existing.item.latest.source = .codec + } + merged[entry.command] = existing + } else { + merged[entry.command] = (HistoryItem(command: entry.command, latest: entry, count: 1, match: nil), order, + entry.directory.map { [$0] } ?? []) + order += 1 + } + } + return Array(merged.values) + } + // MARK: Codec's history file /// One line of Codec's history log: an entry, or an instruction that applies to what came before. diff --git a/Sources/CodecCore/CommandLineEditing.swift b/Sources/CodecCore/CommandLineEditing.swift index 9684382..739ec58 100644 --- a/Sources/CodecCore/CommandLineEditing.swift +++ b/Sources/CodecCore/CommandLineEditing.swift @@ -29,6 +29,9 @@ public enum CommandLineEditing { case copy /// Redraws the prompt with fresh information (after switching branches), keeping what's typed. case refreshPrompt + /// Marks the command line as put there whole (from history), so it suggests nothing more until + /// it's edited, as zsh-autosuggestions does after ↑. + case recalled /// `ESC [ ~` public var sequence: [UInt8] { Array("\u{1b}[\(rawValue)~".utf8) } diff --git a/Sources/CodecCore/ShellIntegration.swift b/Sources/CodecCore/ShellIntegration.swift index ffd97b7..1e4b3a6 100644 --- a/Sources/CodecCore/ShellIntegration.swift +++ b/Sources/CodecCore/ShellIntegration.swift @@ -183,6 +183,7 @@ public struct ShellIntegration: Sendable { [[ -n "$CODEC_PROMPT_FILE" && -r "$CODEC_PROMPT_FILE" ]] && source "$CODEC_PROMPT_FILE" zle reset-prompt } + _codec_recalled() { typeset -g _codec_recalled_line=$BUFFER; _codec_clear_suggestion } _codec_cut() { (( REGION_ACTIVE )) && { _codec_copy; zle kill-region }; REGION_ACTIVE=0; return 0 } _codec_delete_to_start() { if (( REGION_ACTIVE )); then zle kill-region; REGION_ACTIVE=0; else zle backward-kill-line; fi @@ -237,7 +238,7 @@ public struct ShellIntegration: Sendable { _codec_select_to_start _codec_select_to_end _codec_mark _codec_unmark _codec_step_left \ _codec_step_right _codec_select_all _codec_select_word _codec_cut _codec_delete_to_start \ _codec_move_to_start _codec_move_to_end _codec_save_cursor _codec_restore_cursor \ - _codec_set_anchor _codec_to_anchor _codec_copy _codec_refresh_prompt; do + _codec_set_anchor _codec_to_anchor _codec_copy _codec_refresh_prompt _codec_recalled; do zle -N $widget done local map @@ -253,13 +254,14 @@ public struct ShellIntegration: Sendable { $'\e[57010~' _codec_delete_to_start $'\e[57011~' _codec_move_to_start $'\e[57012~' _codec_move_to_end \ $'\e[57013~' _codec_save_cursor $'\e[57014~' _codec_restore_cursor \ $'\e[57015~' _codec_set_anchor $'\e[57016~' _codec_to_anchor $'\e[57017~' _codec_copy \ - $'\e[57018~' _codec_refresh_prompt + $'\e[57018~' _codec_refresh_prompt $'\e[57019~' _codec_recalled # Forward delete, if nothing else claims it. [[ $(bindkey -M $map $'\e[3~') == *undefined-key ]] && bindkey -M $map $'\e[3~' delete-char done autoload -Uz add-zle-hook-widget && add-zle-hook-widget line-pre-redraw _codec_collapse_on_move add-zle-hook-widget line-pre-redraw _codec_suggest add-zle-hook-widget line-finish _codec_clear_suggestion + add-zle-hook-widget line-finish _codec_forget_recalled # Show the selection in the theme's selection color, unless the user styled it. [[ -n $CODEC_SELECTION_STYLE && ${(j: :)zle_highlight} != *region:* ]] && zle_highlight+=(region:$CODEC_SELECTION_STYLE) } @@ -279,13 +281,14 @@ public struct ShellIntegration: Sendable { _codec_suggesting || return 0 _codec_clear_suggestion (( CURSOR == $#BUFFER && ! REGION_ACTIVE && PENDING == 0 )) || return 0 - [[ -n $BUFFER && $BUFFER != *$'\n'* && $KEYMAP != vicmd ]] || return 0 + [[ -n $BUFFER && $BUFFER != *$'\n'* && $KEYMAP != vicmd && $BUFFER != "$_codec_recalled_line" ]] || return 0 # The most recent command that starts with what's typed and goes further. local match=${history[(r)${(b)BUFFER}?*]} [[ -n $match && $match != *$'\n'* ]] || return 0 POSTDISPLAY=${match#$BUFFER} region_highlight+=("$#BUFFER $(( $#BUFFER + $#POSTDISPLAY )) $_codec_suggestion_style,memo=codec") } + _codec_forget_recalled() { _codec_recalled_line= } _codec_take_suggestion() { [[ -n $POSTDISPLAY ]] && (( CURSOR == $#BUFFER )) || return 1 BUFFER+=$POSTDISPLAY diff --git a/Sources/CodecUI/HistoryMenu.swift b/Sources/CodecUI/HistoryMenu.swift new file mode 100644 index 0000000..8b8ccd0 --- /dev/null +++ b/Sources/CodecUI/HistoryMenu.swift @@ -0,0 +1,108 @@ +import SwiftUI +import AppKit +import CodecCore + +/// Where ↑ has got to in the history: the commands it walks through, newest first, and the one on +/// the command line now. +@MainActor +final class HistoryMenuModel: ObservableObject { + /// What was on the line before ↑, to put back on the way out. + let typed: String + @Published private(set) var items: [HistoryItem] + @Published private(set) var selection = 0 + + /// Nil when nothing in the history matches what was typed. + init?(typed: String, items: [HistoryItem]) { + guard !items.isEmpty else { return nil } + self.typed = typed + self.items = items + } + + var command: String { items[selection].command } + + /// One step back in time. False at the oldest. + func older() -> Bool { + guard selection + 1 < items.count else { return false } + selection += 1 + return true + } + + /// One step forward. False at the newest: next is what was typed. + func newer() -> Bool { + guard selection > 0 else { return false } + selection -= 1 + return true + } + + func select(_ index: Int) { + if items.indices.contains(index) { selection = index } + } + + /// Takes a command out of the list. False when that leaves nothing. + func remove(_ command: String) -> Bool { + guard let index = items.firstIndex(where: { $0.command == command }) else { return true } + items.remove(at: index) + if index < selection || selection == items.count { selection = max(selection - 1, 0) } + return !items.isEmpty + } +} + +/// The ↑ menu, above the prompt with the newest command nearest it. It never takes the keyboard: +/// the window controller moves it with ↑ and ↓, and every other key goes on to the command line, +/// which already holds the highlighted command. +struct HistoryMenuView: View { + @ObservedObject var model: HistoryMenuModel + let theme: CodecTheme + let codeFont: NSFont + var onPick: (Int) -> Void + var onForget: (String) -> Void + + var body: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(Array(model.items.enumerated()).reversed(), id: \.element.id) { index, item in + HistoryRow(item: item, compact: true, selected: index == model.selection, theme: theme, codeFont: codeFont) + .id(item.id) + .onTapGesture { onPick(index) } + .contextMenu { + Button("Copy Command") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(item.command, forType: .string) + } + Divider() + Button("Remove from History", role: .destructive) { onForget(item.command) } + } + } + } + .padding(6) + } + .frame(maxHeight: 270) + .defaultScrollAnchor(.bottom) + .onChange(of: model.selection) { _, new in + if model.items.indices.contains(new) { proxy.scrollTo(model.items[new].id) } + } + } + Divider().opacity(0.5) + HStack(spacing: 14) { + KeyHint(key: "↑↓", label: "Browse") + KeyHint(key: "↩", label: "Run") + if !model.typed.trimmingCharacters(in: .whitespaces).isEmpty { + Text("Matching “\(model.typed)”") + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + KeyHint(key: "esc", label: "Cancel") + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.03)) + } + .frame(width: 560) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/Sources/CodecUI/HistoryView.swift b/Sources/CodecUI/HistoryView.swift index 4f42557..03da7d7 100644 --- a/Sources/CodecUI/HistoryView.swift +++ b/Sources/CodecUI/HistoryView.swift @@ -11,11 +11,9 @@ enum HistoryChoice: Equatable { case copy(String) } -/// The command history, searchable. As a menu, it opens above the prompt with the newest command -/// nearest the prompt, so ↑ walks back in time; as a panel, it's a full search with filters. +/// The command history, searchable, with filters: ⌃R at the prompt, or ⌘Y. (↑ has its own menu, +/// `HistoryMenuView`.) struct HistoryView: View { - enum Style { case menu, panel } - enum Scope: String, CaseIterable, Identifiable { case all, here, failed var id: String { rawValue } @@ -29,7 +27,6 @@ struct HistoryView: View { } @ObservedObject var store: HistoryStore - let style: Style let theme: CodecTheme let codeFont: NSFont /// The focused pane's directory, to prefer and filter by. @@ -44,10 +41,9 @@ struct HistoryView: View { @State private var selection = 0 @FocusState private var fieldFocused: Bool - init(store: HistoryStore, style: Style, theme: CodecTheme, codeFont: NSFont, directory: String?, canInsert: Bool, + init(store: HistoryStore, theme: CodecTheme, codeFont: NSFont, directory: String?, canInsert: Bool, query: String, onChoose: @escaping (HistoryChoice) -> Void, onDismiss: @escaping () -> Void) { self.store = store - self.style = style self.theme = theme self.codeFont = codeFont self.directory = directory @@ -63,30 +59,24 @@ struct HistoryView: View { case .here: directory.map(HistoryFilter.directory) ?? .all case .failed: .failed } - return store.history.items(matching: query, filter: filter, directory: directory, limit: style == .menu ? 60 : 400) + return store.history.items(matching: query, filter: filter, directory: directory, limit: 400) } var body: some View { let items = items VStack(spacing: 0) { - if style == .panel { - searchField - Divider().opacity(0.5) - scopePicker - } + searchField + Divider().opacity(0.5) + scopePicker list(items) - if style == .menu { - Divider().opacity(0.5) - searchField - } footer } - .frame(width: style == .menu ? 560 : 720) + .frame(width: 720) .fixedSize(horizontal: false, vertical: true) .onChange(of: query) { selection = 0 } .onChange(of: scope) { selection = 0 } - .onKeyPress(.upArrow) { move(style == .menu ? 1 : -1, count: items.count); return .handled } - .onKeyPress(.downArrow) { move(style == .menu ? -1 : 1, count: items.count); return .handled } + .onKeyPress(.upArrow) { move(-1, count: items.count); return .handled } + .onKeyPress(.downArrow) { move(1, count: items.count); return .handled } .onKeyPress(keys: [.return], phases: .down) { press in choose(items, run: press.modifiers.contains(.command)) return .handled @@ -104,15 +94,15 @@ struct HistoryView: View { private var searchField: some View { HStack(spacing: 10) { - Image(systemName: style == .menu ? "clock.arrow.circlepath" : "magnifyingglass") + Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) - TextField(style == .menu ? "Filter history" : "Search command history", text: $query) + TextField("Search command history", text: $query) .textFieldStyle(.plain) - .font(style == .menu ? .system(size: 13, design: .monospaced) : .system(size: 15)) + .font(.system(size: 15)) .focused($fieldFocused) } - .padding(.horizontal, style == .menu ? 12 : 16) - .padding(.vertical, style == .menu ? 9 : 13) + .padding(.horizontal, 16) + .padding(.vertical, 13) } private var scopePicker: some View { @@ -144,9 +134,8 @@ struct HistoryView: View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 1) { - let ordered = style == .menu ? Array(items.enumerated()).reversed() : Array(items.enumerated()) - ForEach(ordered, id: \.element.id) { index, item in - if style == .panel, query.isEmpty, let header = dayHeader(at: index, in: items) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + if query.isEmpty, let header = dayHeader(at: index, in: items) { Text(header) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) @@ -154,7 +143,7 @@ struct HistoryView: View { .padding(.top, index == 0 ? 4 : 10) .padding(.bottom, 2) } - HistoryRow(item: item, style: style, selected: index == selection, theme: theme, codeFont: codeFont) + HistoryRow(item: item, compact: false, selected: index == selection, theme: theme, codeFont: codeFont) .id(item.id) .onTapGesture { selection = index; choose(items, run: false) } .contextMenu { rowMenu(item) } @@ -168,8 +157,8 @@ struct HistoryView: View { } .padding(6) } - .frame(maxHeight: style == .menu ? 270 : 440) - .defaultScrollAnchor(style == .menu ? .bottom : .top) + .frame(maxHeight: 440) + .defaultScrollAnchor(.top) .onChange(of: selection) { _, new in if items.indices.contains(new) { proxy.scrollTo(items[new].id) } } @@ -213,11 +202,11 @@ struct HistoryView: View { @ViewBuilder private var footer: some View { HStack(spacing: 14) { - hint("↩", canInsert ? "Insert" : "Copy") - if canInsert { hint("⌘↩", "Run") } - hint("⌘⌫", "Remove") + KeyHint(key: "↩", label: canInsert ? "Insert" : "Copy") + if canInsert { KeyHint(key: "⌘↩", label: "Run") } + KeyHint(key: "⌘⌫", label: "Remove") Spacer() - hint("esc", "Close") + KeyHint(key: "esc", label: "Close") } .font(.system(size: 11)) .foregroundStyle(.secondary) @@ -226,15 +215,6 @@ struct HistoryView: View { .background(Color.primary.opacity(0.03)) } - private func hint(_ key: String, _ label: String) -> some View { - HStack(spacing: 4) { - Text(key).font(.system(size: 10, weight: .semibold, design: .rounded)) - .padding(.horizontal, 4).padding(.vertical, 1) - .background(RoundedRectangle(cornerRadius: 4).fill(Color.primary.opacity(0.08))) - Text(label) - } - } - private func move(_ delta: Int, count: Int) { guard count > 0 else { return } selection = min(max(selection + delta, 0), count - 1) @@ -247,10 +227,26 @@ struct HistoryView: View { } } +/// A key and what it does, for a footer. +struct KeyHint: View { + let key: String + let label: String + + var body: some View { + HStack(spacing: 4) { + Text(key).font(.system(size: 10, weight: .semibold, design: .rounded)) + .padding(.horizontal, 4).padding(.vertical, 1) + .background(RoundedRectangle(cornerRadius: 4).fill(Color.primary.opacity(0.08))) + Text(label) + } + } +} + /// One command: highlighted like the shell would color it, with where and how it last ran. -private struct HistoryRow: View { +/// Compact, it leaves out how often, where and how long, and sits a little tighter. +struct HistoryRow: View { let item: HistoryItem - let style: HistoryView.Style + let compact: Bool let selected: Bool let theme: CodecTheme let codeFont: NSFont @@ -265,10 +261,10 @@ private struct HistoryRow: View { .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) - if item.count > 1, style == .panel { + if item.count > 1, !compact { Text("×\(item.count)").font(.system(size: 11).monospacedDigit()).foregroundStyle(.tertiary) } - if style == .panel, let directory = item.latest.directory { + if !compact, let directory = item.latest.directory { Text((directory as NSString).abbreviatingWithTildeInPath) .font(.system(size: 11)) .foregroundStyle(.secondary) @@ -276,7 +272,7 @@ private struct HistoryRow: View { .truncationMode(.head) .frame(maxWidth: 190, alignment: .trailing) } - if style == .panel, let duration = item.latest.duration, duration >= 1 { + if !compact, let duration = item.latest.duration, duration >= 1 { Text(Formatting.duration(duration)).font(.system(size: 11).monospacedDigit()).foregroundStyle(.secondary) } if item.latest.date != .distantPast { @@ -287,7 +283,7 @@ private struct HistoryRow: View { } } .padding(.horizontal, 10) - .frame(height: style == .menu ? 28 : 32) + .frame(height: compact ? 28 : 32) .background(RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(selected ? Color.accentColor.opacity(0.22) : .clear)) .contentShape(Rectangle()) diff --git a/Sources/CodecUI/Pane.swift b/Sources/CodecUI/Pane.swift index a619983..1f931d0 100644 --- a/Sources/CodecUI/Pane.swift +++ b/Sources/CodecUI/Pane.swift @@ -1033,12 +1033,14 @@ final class Pane: LocalProcessTerminalViewDelegate { private var runningDirectory: String? /// Puts a command on the command line in place of what's typed, as a paste so nothing in it is - /// taken as a key, and runs it if asked. False when the shell isn't at its prompt. + /// taken as a key, and runs it if asked. Left there, it gets no suggestion after it until it's + /// edited. False when the shell isn't at its prompt. @discardableResult func replaceCommandLine(with command: String, run: Bool) -> Bool { guard isAtCommandLine else { return false } terminal.send(CommandLineEditing.Action.selectAll.sequence) - terminal.send(txt: "\u{1b}[200~" + command.replacingOccurrences(of: "\u{1b}", with: "") + "\u{1b}[201~" + (run ? "\r" : "")) + terminal.send(txt: "\u{1b}[200~" + command.replacingOccurrences(of: "\u{1b}", with: "") + "\u{1b}[201~") + terminal.send(run ? [0x0d] : CommandLineEditing.Action.recalled.sequence) return true } diff --git a/Sources/CodecUI/TerminalWindowController.swift b/Sources/CodecUI/TerminalWindowController.swift index df0e338..c0b76db 100644 --- a/Sources/CodecUI/TerminalWindowController.swift +++ b/Sources/CodecUI/TerminalWindowController.swift @@ -928,7 +928,10 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe private var historyPopover: NSPopover? private var historyPopoverClosed: NSObjectProtocol? - var isShowingHistoryMenu: Bool { historyPopover?.isShown == true } + /// While the ↑ menu is open: where it has got to, and the pane whose command line shows it. + private var historyMenu: HistoryMenuModel? + private weak var historyMenuPane: Pane? + var isShowingHistoryMenu: Bool { historyMenu != nil } private(set) var isShowingHistoryPanel = false func showHistory(for pane: Pane, request: IntegrationEvent.HistoryRequest, typed: String) { @@ -968,11 +971,10 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe showHistoryPanel(for: pane, typed: "") } - private func historyView(for pane: Pane, style: HistoryView.Style, typed: String, - dismiss: @escaping () -> Void) -> HistoryView { + private func historyView(for pane: Pane, typed: String, dismiss: @escaping () -> Void) -> HistoryView { // Picks up commands other terminals added to the shell's history file. environment.history.load() - return HistoryView(store: environment.history, style: style, theme: preferences.theme, codeFont: preferences.font(), + return HistoryView(store: environment.history, theme: preferences.theme, codeFont: preferences.font(), directory: pane.session.directory, canInsert: pane.isAtCommandLine, query: typed, onChoose: { [weak self, weak pane] choice in dismiss() @@ -992,41 +994,116 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe if let pane { window?.makeFirstResponder(pane.view.preferredResponder) } } - /// ↑ at the prompt: a menu just above the cursor. + /// ↑ at the prompt, as in any shell: the newest command matching what's typed goes on the line, + /// with a menu of the others just above it. The keyboard stays on the command line: ↑ and ↓ step + /// through the menu and Esc puts back what was typed, and any other key closes the menu and does + /// what it always does there, so Return runs the command and ⌫ edits it. private func showHistoryMenu(for pane: Pane, typed: String) { - historyPopover?.close() + // An ↑ that reached the shell just before the menu opened. + if isShowingHistoryMenu, historyMenuPane === pane { return stepHistoryMenu(older: true) } + closeHistoryMenu() guard let grid = TerminalGrid(terminal: pane.terminal) else { return showHistoryPanel(for: pane, typed: typed) } + // Commands other terminals added to the shell's history file show up from the next ↑ on. + environment.history.load() + guard let menu = HistoryMenuModel(typed: typed, items: environment.history.history.recall(typed, limit: 60)) else { + return NSSound.beep() + } let popover = NSPopover() popover.behavior = .transient - let controller = NSHostingController(rootView: historyView(for: pane, style: .menu, typed: typed) { [weak popover] in - popover?.performClose(nil) - }) + let controller = NSHostingController(rootView: HistoryMenuView( + model: menu, theme: preferences.theme, codeFont: preferences.font(), + onPick: { [weak self] index in self?.pickFromHistoryMenu(index) }, + onForget: { [weak self] command in self?.forgetFromHistoryMenu(command) })) controller.sizingOptions = .preferredContentSize popover.contentViewController = controller historyPopoverClosed = NotificationCenter.default.addObserver( - forName: NSPopover.didCloseNotification, object: popover, queue: .main) { [weak self, weak pane] _ in + forName: NSPopover.didCloseNotification, object: popover, queue: .main) { [weak self, weak pane, weak popover] _ in MainActor.assumeIsolated { - guard let self else { return } + // A menu that closed after the next one opened leaves the next one be. + guard let self, self.historyPopover === popover else { return } if let observer = self.historyPopoverClosed { NotificationCenter.default.removeObserver(observer) } self.historyPopoverClosed = nil self.historyPopover = nil + self.historyMenu = nil + self.historyMenuPane = nil self.stopWatchingOverlayKeys() if let pane, !self.model.paletteOpen { self.window?.makeFirstResponder(pane.view.preferredResponder) } } } historyPopover = popover + historyMenu = menu + historyMenuPane = pane popover.show(relativeTo: pane.terminal.convert(grid.caret, from: nil), of: pane.terminal, preferredEdge: .maxY) + window?.makeKey() + window?.makeFirstResponder(pane.view.preferredResponder) watchOverlayKeys() - focusSearchField(in: controller.view) + pane.replaceCommandLine(with: menu.command, run: false) + } + + /// A key pressed while the ↑ menu is open. True when the menu used it up; any other key closes + /// the menu and goes on to the command line. + private func handleHistoryMenuKey(_ event: NSEvent) -> Bool { + let plain = event.modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty + switch event.keyCode { + case 126 where plain: stepHistoryMenu(older: true) + case 125 where plain: stepHistoryMenu(older: false) + case 53: cancelHistoryMenu() + default: + closeHistoryMenu() + // Clicking the menu can leave it the key window; the key belongs to the command line. + guard let window, event.window !== window else { return false } + window.makeKey() + window.sendEvent(event) + } + return true + } + + /// ↑ or ↓ in the menu: the next command on the line. ↓ past the newest goes back to what was typed. + private func stepHistoryMenu(older: Bool) { + guard let menu = historyMenu, let pane = historyMenuPane else { return } + if older { + guard menu.older() else { return NSSound.beep() } + } else if !menu.newer() { + return cancelHistoryMenu() + } + pane.replaceCommandLine(with: menu.command, run: false) + } + + /// A click on a command: on the line to edit. + private func pickFromHistoryMenu(_ index: Int) { + guard let menu = historyMenu, let pane = historyMenuPane else { return } + menu.select(index) + pane.replaceCommandLine(with: menu.command, run: false) + closeHistoryMenu() + } + + private func forgetFromHistoryMenu(_ command: String) { + guard let menu = historyMenu, let pane = historyMenuPane else { return } + environment.history.forget(command) + guard menu.remove(command) else { return cancelHistoryMenu() } + pane.replaceCommandLine(with: menu.command, run: false) + } + + /// Esc: the menu goes, and what was typed comes back. + private func cancelHistoryMenu() { + if let menu = historyMenu { historyMenuPane?.replaceCommandLine(with: menu.typed, run: false) } + closeHistoryMenu() + } + + /// Closes the menu at once, leaving the command line as it is. + private func closeHistoryMenu() { + historyMenu = nil + historyMenuPane = nil + historyPopover?.close() } /// ⌃R at the prompt, or ⌘Y: the full search, over the window like the command palette. private func showHistoryPanel(for pane: Pane, typed: String) { - historyPopover?.close() + closeHistoryMenu() if model.paletteOpen { hideCommandPalette() } model.paletteOpen = true isShowingHistoryPanel = true - let view = historyView(for: pane, style: .panel, typed: typed) { [weak self] in self?.hideCommandPalette() } + let view = historyView(for: pane, typed: typed) { [weak self] in self?.hideCommandPalette() } palette.rootView = AnyView( ZStack(alignment: .top) { Color.black.opacity(0.001) @@ -1045,7 +1122,7 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe private var overlayKeyMonitor: Any? - /// Puts the keyboard in an overlay's search field. Opened from the shell (⌃R, ↑), the overlay + /// Puts the keyboard in an overlay's search field. Opened from the shell (⌃R), the overlay /// appears outside a key event, and SwiftUI's own focus request doesn't always land. /// SwiftUI builds the field a little later the first time, and its own focus handling can let go /// of it again while it settles. So for up to a second this keeps putting the keyboard back in the @@ -1082,8 +1159,8 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe return responder === field || responder.isDescendant(of: field) || (responder as? NSText)?.delegate === field } - /// While the palette, the history panel or the history menu is open: Esc closes it, and typing - /// goes to its search field, wherever the keyboard ended up. + /// While the palette or the history panel is open: Esc closes it, and typing goes to its search + /// field, wherever the keyboard ended up. While the ↑ menu is open, ↑, ↓ and Esc go to the menu. private func watchOverlayKeys() { guard overlayKeyMonitor == nil else { return } overlayKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in @@ -1102,18 +1179,7 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe func handleOverlayKey(_ event: NSEvent) -> Bool { let menuWindow = historyPopover?.contentViewController?.view.window guard event.window === window || (menuWindow != nil && event.window === menuWindow) else { return false } - if let popover = historyPopover, popover.isShown { - if event.keyCode == 53 { - popover.performClose(nil) - return true - } - if let content = popover.contentViewController?.view, let field = Self.firstTextField(in: content), - let menuWindow, !Self.isEditing(field, in: menuWindow) { - menuWindow.makeKey() - menuWindow.makeFirstResponder(field) - } - return false - } + if isShowingHistoryMenu { return handleHistoryMenuKey(event) } guard model.paletteOpen else { stopWatchingOverlayKeys() return false diff --git a/Tests/CodecCoreTests/CommandHistoryTests.swift b/Tests/CodecCoreTests/CommandHistoryTests.swift index 6a941bc..687d02d 100644 --- a/Tests/CodecCoreTests/CommandHistoryTests.swift +++ b/Tests/CodecCoreTests/CommandHistoryTests.swift @@ -24,6 +24,16 @@ struct CommandHistoryTests { ]) } + @Test func recallsWhatStartsWithTheTypedTextThenWhatContainsItNewestFirst() { + // Unlike the search: case and the current folder don't matter, only how recent. + #expect(sample.recall("git").map(\.command) == ["Git log", "git status", "git commit -m 'Fix'"]) + // An older command starting with it comes before newer ones that only contain it. + #expect(sample.recall("m").map(\.command) == ["make test", "cd ~/Development", "npm run dev", "git commit -m 'Fix'"]) + #expect(sample.recall("status").first?.match == 4..<10) + #expect(sample.recall(" ").map(\.command) == sample.items(matching: "").map(\.command)) + #expect(sample.recall("xyz").isEmpty) + } + @Test func listsTheMostRecentFirstMergingRepeats() { let items = sample.items(matching: "") #expect(items.map(\.command) == ["cd ~/Development", "npm run dev", "Git log", "git status", "git commit -m 'Fix'", "make test"]) diff --git a/Tests/CodecUITests/HistoryTests.swift b/Tests/CodecUITests/HistoryTests.swift index fbc0cb5..bcefb5c 100644 --- a/Tests/CodecUITests/HistoryTests.swift +++ b/Tests/CodecUITests/HistoryTests.swift @@ -210,6 +210,7 @@ struct OverlayKeyboardTests { @Test func escapeClosesTheHistoryMenu() async throws { let env = try TestEnvironment() defer { env.tearDown() } + env.environment.history.record(HistoryEntry(command: "make test", date: Date(), directory: env.directory.path, exitCode: 0)) let (controller, window, pane) = try makeWindow(env) defer { window.close() } controller.showHistory(for: pane, request: .menu, typed: "") @@ -218,3 +219,178 @@ struct OverlayKeyboardTests { #expect(await waitUntil { !controller.isShowingHistoryMenu }) } } + +@Suite("History menu on the command line", .serialized, .timeLimit(.minutes(1)), + .enabled(if: FileManager.default.isExecutableFile(atPath: "/bin/zsh"))) +@MainActor +struct HistoryMenuLineTests { + private func environment(suggestions: Bool = false) throws -> TestEnvironment { + let env = try TestEnvironment(shell: "/bin/zsh", integration: true) + // Most tests leave out the dimmed suggestion after the cursor, which would read as part of the line. + env.preferences.suggestCommands = suggestions + for (offset, command) in ["echo older", "echo newer"].enumerated() { + env.environment.history.record(HistoryEntry(command: command, date: Date(timeIntervalSinceNow: Double(offset - 10)), + directory: env.directory.path, exitCode: 0)) + } + return env + } + + private func makeWindow(_ env: TestEnvironment) throws -> (TerminalWindowController, NSWindow, Pane) { + let config = env.directory.appendingPathComponent("zdotdir") + try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true) + try "PS1='%# '\nHISTFILE=\(config.path)/.zsh_history\n".write(to: config.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) + setenv("ZDOTDIR", config.path, 1) + defer { unsetenv("ZDOTDIR") } + let controller = TerminalWindowController(directory: env.directory.path, environment: env.environment) + let window = try #require(controller.window) + window.setContentSize(NSSize(width: 1000, height: 600)) + window.orderBack(nil) + return (controller, window, try #require(controller.model.selected?.focused)) + } + + /// The prompt's row: the last one with anything on it. + private func line(_ pane: Pane) -> String { + pane.terminal.terminalContentSnapshot(region: .history(maximumScrollbackRows: 100))?.rows + .map { $0.text.trimmingCharacters(in: .whitespaces) } + .last { !$0.isEmpty } ?? "" + } + + /// Presses a key the way the app does: the window's key monitor sees it first, and what it doesn't + /// use goes on to the terminal. + private func press(_ code: UInt16, _ characters: String, function: Bool = false, in window: NSWindow, + _ controller: TerminalWindowController) throws { + let event = try #require(NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: function ? [.function, .numericPad] : [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, + characters: characters, charactersIgnoringModifiers: characters, isARepeat: false, keyCode: code)) + if !controller.handleOverlayKey(event) { window.sendEvent(event) } + } + + private func up(_ window: NSWindow, _ controller: TerminalWindowController) throws { + try press(126, "\u{F700}", function: true, in: window, controller) + } + + private func down(_ window: NSWindow, _ controller: TerminalWindowController) throws { + try press(125, "\u{F701}", function: true, in: window, controller) + } + + @Test func upPutsEachCommandOnTheLineAndDownGoesBackToWhatWasTyped() async throws { + let env = try environment() + defer { env.tearDown() } + let (controller, window, pane) = try makeWindow(env) + defer { window.close() } + #expect(await waitUntil { pane.isAtCommandLine }) + pane.terminal.send(txt: "echo") + #expect(await waitUntil { line(pane) == "% echo" }) + + // ↑ from the shell: the newest match is on the line straight away, and the keyboard stays there. + controller.showHistory(for: pane, request: .menu, typed: "echo") + #expect(await waitUntil { line(pane) == "% echo newer" }) + #expect(controller.isShowingHistoryMenu) + #expect(window.firstResponder === pane.terminal) + try up(window, controller) + #expect(await waitUntil { line(pane) == "% echo older" }) + // Past the oldest, it stays put. + try up(window, controller) + try down(window, controller) + #expect(await waitUntil { line(pane) == "% echo newer" }) + try down(window, controller) + #expect(await waitUntil { line(pane) == "% echo" }) + #expect(!controller.isShowingHistoryMenu) + #expect(pane.session.lastCommand == nil) + } + + @Test func returnRunsTheCommandOnTheLine() async throws { + let env = try environment() + defer { env.tearDown() } + let (controller, window, pane) = try makeWindow(env) + defer { window.close() } + #expect(await waitUntil { pane.isAtCommandLine }) + + controller.showHistory(for: pane, request: .menu, typed: "") + #expect(await waitUntil { line(pane) == "% echo newer" }) + try up(window, controller) + #expect(await waitUntil { line(pane) == "% echo older" }) + try press(36, "\r", in: window, controller) + #expect(!controller.isShowingHistoryMenu) + #expect(await waitUntil { pane.session.lastCommand?.command == "echo older" }) + } + + /// Any other key edits the command on the line, as it would after zsh's own ↑. + @Test func otherKeysCloseTheMenuAndEditTheLine() async throws { + let env = try environment() + defer { env.tearDown() } + let (controller, window, pane) = try makeWindow(env) + defer { window.close() } + #expect(await waitUntil { pane.isAtCommandLine }) + pane.terminal.send(txt: "echo") + #expect(await waitUntil { line(pane) == "% echo" }) + + controller.showHistory(for: pane, request: .menu, typed: "echo") + #expect(await waitUntil { line(pane) == "% echo newer" }) + try press(51, "\u{7f}", in: window, controller) + #expect(!controller.isShowingHistoryMenu) + #expect(await waitUntil { line(pane) == "% echo newe" }) + try press(0, "r", in: window, controller) + try press(18, "!", in: window, controller) + #expect(await waitUntil { line(pane) == "% echo newer!" }) + #expect(pane.session.lastCommand == nil) + } + + @Test func escapePutsBackWhatWasTyped() async throws { + let env = try environment() + defer { env.tearDown() } + let (controller, window, pane) = try makeWindow(env) + defer { window.close() } + #expect(await waitUntil { pane.isAtCommandLine }) + pane.terminal.send(txt: "echo") + #expect(await waitUntil { line(pane) == "% echo" }) + + controller.showHistory(for: pane, request: .menu, typed: "echo") + #expect(await waitUntil { line(pane) == "% echo newer" }) + // An ↑ that reached the shell just before the menu opened is one more step back. + controller.showHistory(for: pane, request: .menu, typed: "echo newer") + #expect(await waitUntil { line(pane) == "% echo older" }) + try press(53, "\u{1b}", in: window, controller) + #expect(await waitUntil { line(pane) == "% echo" }) + #expect(!controller.isShowingHistoryMenu) + #expect(pane.session.lastCommand == nil) + } + + @Test func nothingMatchingLeavesTheLineAlone() async throws { + let env = try environment() + defer { env.tearDown() } + let (controller, _, pane) = try makeWindow(env) + defer { controller.window?.close() } + #expect(await waitUntil { pane.isAtCommandLine }) + pane.terminal.send(txt: "zzz") + #expect(await waitUntil { line(pane) == "% zzz" }) + controller.showHistory(for: pane, request: .menu, typed: "zzz") + #expect(!controller.isShowingHistoryMenu) + try await Task.sleep(for: .milliseconds(300)) + #expect(line(pane) == "% zzz") + } + + /// A recalled command shows no suggestion after it until it's edited, as with zsh-autosuggestions. + @Test func aRecalledCommandSuggestsNothingUntilEdited() async throws { + let env = try environment(suggestions: true) + defer { env.tearDown() } + let (controller, window, pane) = try makeWindow(env) + defer { window.close() } + #expect(await waitUntil { pane.isAtCommandLine }) + // In the shell's own history, which suggestions come from. + pane.terminal.send(txt: "echo newer && echo more\r") + #expect(await waitUntil { pane.session.lastCommand?.command == "echo newer && echo more" }) + #expect(await waitUntil { env.environment.history.history.entries.last?.command == "echo newer && echo more" }) + + controller.showHistory(for: pane, request: .menu, typed: "echo n") + #expect(await waitUntil { line(pane) == "% echo newer && echo more" }) + try up(window, controller) + #expect(await waitUntil { line(pane) == "% echo newer" }) + try await Task.sleep(for: .milliseconds(300)) + #expect(line(pane) == "% echo newer") + // Edited, it suggests again. + try press(49, " ", in: window, controller) + #expect(await waitUntil { line(pane) == "% echo newer && echo more" }) + } +} diff --git a/docs/command-line.md b/docs/command-line.md index c37a882..56d371a 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -45,10 +45,16 @@ As you type, the rest of the most recent matching command appears dimmed after t ![The history search](images/history.png) -↑ at the prompt opens a menu just above it, filtered by what you've typed, the newest command nearest the prompt: +↑ at the prompt works as in any shell: it puts your last command on the line, and each ↑ goes one further back. If you've typed something, it goes through commands starting with it, then commands containing it, newest first. A menu just above the prompt shows where you are among them, the newest nearest the prompt. -- ↑ / ↓ to browse, type to narrow it down, -- Return puts the command on the line, ⌘Return runs it. +The keyboard stays on the command line the whole time: + +- ↑ / ↓ move through the menu, the line following along, +- Return runs the command on the line, +- any other key (⌫, typing, ←, →, Tab…) closes the menu and edits the command, as it would after zsh's own ↑, +- Esc, or ↓ past the newest command, puts back what you'd typed. + +Click a command to put it on the line, or right-click it to copy it or remove it from history. When nothing matches what you've typed, ↑ just beeps. To search everything instead, use the history search below. ## History search diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 9b299be..8fd582f 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -34,7 +34,7 @@ | Keys | Action | |---|---| -| ↑ | History menu (at the prompt) | +| ↑ | Previous command, with the history menu (at the prompt): ↑ / ↓ step through it, Return runs it, Esc goes back | | ⌃R, ⌘Y | History search (⌃R at the prompt, ⌘Y anywhere) | | →, ⌥→ | Take the suggested command, or its next word | | ⌘← / ⌘→ | Move to the start / end of the line |