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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Intel is plain Markdown in folders: yours in `~/.codec/intel`, and your team's i

<img src="docs/images/history.png" alt="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)

Expand Down
69 changes: 48 additions & 21 deletions Sources/CodecCore/CommandHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>)] = [:]
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)
Expand Down Expand Up @@ -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..<typed.count
starting.append(item)
} else if let range = lower.range(of: lowered) {
let start = lower.distance(from: lower.startIndex, to: range.lowerBound)
item.match = start..<(start + typed.count)
containing.append(item)
}
}
return Array((starting + containing).prefix(limit))
}

/// Every command once, with `order` counting from the most recent, and every directory it ran in.
private func merged() -> [(item: HistoryItem, order: Int, directories: Set<String>)] {
var merged: [String: (item: HistoryItem, order: Int, directories: Set<String>)] = [:]
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.
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodecCore/CommandLineEditing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 [ <code> ~`
public var sequence: [UInt8] { Array("\u{1b}[\(rawValue)~".utf8) }
Expand Down
9 changes: 6 additions & 3 deletions Sources/CodecCore/ShellIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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
Expand Down
108 changes: 108 additions & 0 deletions Sources/CodecUI/HistoryMenu.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading