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
40 changes: 30 additions & 10 deletions Sources/CodecCore/CommandBlocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@ public struct CommandBlocks: Equatable, Sendable {
public var prompt: Int
/// The first row after the command line, where output begins.
public var output: Int
/// The command line as it was found, the end of the prompt and the command, so it can be told
/// from output that happens to end with the same command. Nil when not known.
public var commandLine: String?

public init(prompt: Int, output: Int) {
public init(prompt: Int, output: Int, commandLine: String? = nil) {
self.prompt = prompt
self.output = output
self.commandLine = commandLine
}
}

Expand Down Expand Up @@ -64,7 +68,14 @@ public struct CommandBlocks: Equatable, Sendable {

/// Finds the command line that ends with `command` at or above `cursorRow`, and returns the block it
/// opens. `promptLines` is how many lines the prompt has, its command line included.
public static func locate(command: String, in rows: [ScreenRow], cursorRow: Int, promptLines: Int) -> Block? {
///
/// Any line can end with the command, its output's too: `ls` printing `Pictures Tools`, or `cat list`
/// printing `wildcat list`. That's harmless while the output hasn't started, as when a command is first
/// found, but not when finding it again later. Then `line`, the command line as first found, must
/// match too. Older prompts' ends aren't marked anywhere, only the current one's, so the line's text
/// is what tells the prompt apart from output.
public static func locate(command: String, line: String? = nil, in rows: [ScreenRow], cursorRow: Int,
promptLines: Int) -> Block? {
let wanted = command.trimmingCharacters(in: .whitespaces)
guard !wanted.isEmpty else { return nil }
let candidates = rows.filter { $0.row <= cursorRow }
Expand All @@ -73,9 +84,10 @@ public struct CommandBlocks: Equatable, Sendable {
while index >= 0 {
var first = index
while first > 0, candidates[first].isWrapped { first -= 1 }
let text = candidates[first...index].map(\.text).joined()
if trimmingTrailingSpace(text).hasSuffix(wanted) {
return Block(prompt: candidates[first].row - max(promptLines - 1, 0), output: candidates[index].row + 1)
let text = trimmingTrailingSpace(candidates[first...index].map(\.text).joined())
if text.hasSuffix(wanted), line.map({ sameLine(text, $0) }) ?? true {
return Block(prompt: candidates[first].row - max(promptLines - 1, 0), output: candidates[index].row + 1,
commandLine: String(text))
}
index = first - 1
}
Expand All @@ -88,13 +100,15 @@ public struct CommandBlocks: Equatable, Sendable {
}

/// Finds commands' blocks again after their rows moved, as they do when the terminal rewraps its lines
/// on a resize. `commands` are oldest first; each is looked for above the one after it, starting from
/// `lastRow`. Nil for a command that isn't on screen anymore.
public static func relocate(commands: [String], in rows: [ScreenRow], lastRow: Int, promptLines: Int) -> [Block?] {
/// on a resize. `commands` are oldest first, each with its command line as first found (see `locate`);
/// each is looked for above the one after it, starting from `lastRow`. Nil for a command that isn't on
/// screen anymore.
public static func relocate(commands: [(command: String, line: String?)], in rows: [ScreenRow], lastRow: Int,
promptLines: Int) -> [Block?] {
var found: [Block?] = []
var below = lastRow
for command in commands.reversed() {
let block = locate(command: command, in: rows, cursorRow: below, promptLines: promptLines)
for (command, line) in commands.reversed() {
let block = locate(command: command, line: line, in: rows, cursorRow: below, promptLines: promptLines)
if let block { below = block.prompt - 1 }
found.append(block)
}
Expand All @@ -115,6 +129,12 @@ public struct CommandBlocks: Equatable, Sendable {
return rows[position].row - remaining
}

/// Rewrapping keeps a line's text and only breaks it into rows elsewhere, but where it breaks can add
/// or drop a blank cell, as when a wide character doesn't fit at a row's end. So spaces don't count.
private static func sameLine(_ text: Substring, _ line: String) -> Bool {
text.filter { !$0.isWhitespace } == line.filter { !$0.isWhitespace }
}

private static func trimmingTrailingSpace(_ text: String) -> Substring {
var end = text.endIndex
while end > text.startIndex, text[text.index(before: end)].isWhitespace { end = text.index(before: end) }
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodecCore/CommandTimeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ public struct CommandTimeline: Codable, Equatable, Sendable {
for index in copy.entries.indices {
guard let block = copy.entries[index].block else { continue }
copy.entries[index].block = block.prompt >= firstRow
? CommandBlocks.Block(prompt: block.prompt - firstRow, output: block.output - firstRow) : nil
? CommandBlocks.Block(prompt: block.prompt - firstRow, output: block.output - firstRow,
commandLine: block.commandLine) : nil
}
return copy
}
Expand Down
14 changes: 8 additions & 6 deletions Sources/CodecUI/Pane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -643,8 +643,9 @@ final class Pane: LocalProcessTerminalViewDelegate {
}

/// Every name on the screen that's a file or folder, each looked for where the command that printed
/// it ran. The prompt's are marked too: telling its lines from output by command blocks went wrong
/// once a block was found again on its own output, and a prompt with no command has no block.
/// it ran. The prompt's are marked too: only command blocks tell its lines from output, a prompt
/// where only Return was pressed has none, and a block found in the wrong place would leave output
/// unmarked.
private func openableNames(in grid: TerminalGrid) -> [OpenableName] {
relocateBlocksIfNeeded()
let end = newestBlockEnd
Expand Down Expand Up @@ -861,8 +862,8 @@ final class Pane: LocalProcessTerminalViewDelegate {
max(live.liveTopRow - block.prompt, 0), CodecTerminalView.scrollback))) else { return nil }
let screen = Pane.screenRows(snapshot)
// Row numbers start over when the scrollback is cleared, so check the command is still on its line.
guard CommandBlocks.locate(command: entry.screenCommand, in: screen.rows, cursorRow: commandLine,
promptLines: 1)?.output == block.output else { return nil }
guard CommandBlocks.locate(command: entry.screenCommand, line: block.commandLine, in: screen.rows,
cursorRow: commandLine, promptLines: 1)?.output == block.output else { return nil }
let end: Int
if let index = blocks.blocks.firstIndex(of: block), let next = blocks.blocks.dropFirst(index + 1).first {
end = next.prompt
Expand All @@ -887,8 +888,9 @@ final class Pane: LocalProcessTerminalViewDelegate {
else { return }
rowsMayHaveMoved = false
let placed = timeline.entries.filter { $0.block != nil }
let found = CommandBlocks.relocate(commands: placed.map(\.screenCommand), in: Pane.screenRows(snapshot).rows,
lastRow: end - 1, promptLines: session.promptLines)
let found = CommandBlocks.relocate(commands: placed.map { ($0.screenCommand, $0.block?.commandLine) },
in: Pane.screenRows(snapshot).rows, lastRow: end - 1,
promptLines: session.promptLines)
timeline.setBlocks(Dictionary(uniqueKeysWithValues: zip(placed.map(\.id), found)))
blocks.replace(with: found.compactMap { $0 })
}
Expand Down
70 changes: 64 additions & 6 deletions Tests/CodecCoreTests/CommandBlocksTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import Testing
@testable import CodecCore

Expand All @@ -10,18 +11,20 @@ struct CommandBlocksTests {
@Test func locatesTheCommandLineAndItsPrompt() {
let screen = rows(["", "~/code ⎇ main", "", "❯ make test ", "building…", "done"], from: 40)
let block = CommandBlocks.locate(command: "make test", in: screen, cursorRow: 45, promptLines: 4)
#expect(block == .init(prompt: 40, output: 44))
#expect(block == .init(prompt: 40, output: 44, commandLine: "❯ make test"))
}

/// Output may already be on screen when Codec looks; the command line is found above it.
@Test func ignoresOutputBelowTheCommandLine() {
let screen = rows(["% echo hi", "hi", "% "], from: 7)
#expect(CommandBlocks.locate(command: "echo hi", in: screen, cursorRow: 9, promptLines: 1) == .init(prompt: 7, output: 8))
#expect(CommandBlocks.locate(command: "echo hi", in: screen, cursorRow: 9, promptLines: 1)
== .init(prompt: 7, output: 8, commandLine: "% echo hi"))
}

@Test func joinsSoftWrappedCommandLines() {
let screen = rows(["% echo aaaa", "aaaa bbb", "out"], from: 0, wrapped: [1])
#expect(CommandBlocks.locate(command: "echo aaaaaaaa bbb", in: screen, cursorRow: 2, promptLines: 1) == .init(prompt: 0, output: 2))
#expect(CommandBlocks.locate(command: "echo aaaaaaaa bbb", in: screen, cursorRow: 2, promptLines: 1)
== .init(prompt: 0, output: 2, commandLine: "% echo aaaaaaaa bbb"))
}

@Test func returnsNothingWhenTheCommandIsntOnScreen() {
Expand All @@ -34,9 +37,64 @@ struct CommandBlocksTests {
@Test func relocatesCommandsAfterARewrap() {
let screen = rows(["% echo a", "a", "% ls -la /some/long/pa", "th", "x", "% echo a", "a", "% "], from: 30,
wrapped: [33])
let found = CommandBlocks.relocate(commands: ["echo a", "ls -la /some/long/path", "echo a", "gone"], in: screen,
lastRow: 36, promptLines: 1)
#expect(found == [.init(prompt: 30, output: 31), .init(prompt: 32, output: 34), .init(prompt: 35, output: 36), nil])
let found = CommandBlocks.relocate(commands: [("echo a", "% echo a"), ("ls -la /some/long/path", nil),
("echo a", "% echo a"), ("gone", "% gone")],
in: screen, lastRow: 36, promptLines: 1)
#expect(found == [.init(prompt: 30, output: 31, commandLine: "% echo a"),
.init(prompt: 32, output: 34, commandLine: "% ls -la /some/long/path"),
.init(prompt: 35, output: 36, commandLine: "% echo a"), nil])
}

/// Output can end with its own command, as `ls` printing `Tools` does. Found again, the command goes
/// on its command line, not on that line of output.
@Test(arguments: [
(["❯ ls", "Desktop Pictures Tools", "❯ "], 1),
(["~/code ⎇ main", "", "❯ ls", "Desktop Pictures Tools", "~/code ⎇ main", "", "❯ "], 3),
])
func relocatesACommandAboveOutputThatEndsWithIt(lines: [String], promptLines: Int) throws {
let screen = rows(lines, from: 20)
let commandLine = try #require(screen.first { $0.text == "❯ ls" }).row
// As the screen was when the command started, before its output.
let started = rows(Array(lines.prefix(commandLine - 19)) + [""], from: 20)
let first = try #require(CommandBlocks.locate(command: "ls", in: started, cursorRow: commandLine + 1,
promptLines: promptLines))
#expect(first == .init(prompt: 20, output: commandLine + 1, commandLine: "❯ ls"))
let found = CommandBlocks.relocate(commands: [("ls", first.commandLine)], in: screen,
lastRow: 20 + lines.count - promptLines - 1, promptLines: promptLines)
#expect(found == [first])
}

/// The same, for output that ends with the whole command: `cat list` printing `wildcat list`, here
/// after a rewrap broke both lines differently than before.
@Test(arguments: [1, 3])
func relocatesACommandAboveOutputThatEndsWithAllOfIt(promptLines: Int) {
let prompt = promptLines == 1 ? [] : ["~/code ⎇ main", ""]
let before = rows(prompt + ["❯ cat list", "first.txt", "wildcat list"] + prompt + ["❯ "], from: 0)
let first = CommandBlocks.locate(command: "cat list", in: before, cursorRow: promptLines, promptLines: promptLines)
#expect(first?.prompt == 0)
// Narrower now, the long lines take two rows.
let after = rows(prompt + ["❯ cat l", "ist", "first.txt", "wildc", "at list"] + prompt + ["❯ "], from: 0,
wrapped: [promptLines, promptLines + 3])
let found = CommandBlocks.relocate(commands: [("cat list", first?.commandLine)], in: after,
lastRow: promptLines + 3, promptLines: promptLines)
#expect(found == [.init(prompt: 0, output: promptLines + 1, commandLine: "❯ cat list")])
// Without the command line, the output's line would have won.
#expect(CommandBlocks.relocate(commands: [("cat list", nil)], in: after, lastRow: promptLines + 3,
promptLines: promptLines).first??.output == promptLines + 4)
}

/// Spaces may come and go where a rewrap breaks a line, so they don't count when comparing it.
@Test func comparesCommandLinesWithoutSpaces() {
// 日 didn't fit at the end of the first row, which was left with a blank cell.
let screen = rows(["~/a/long/dir ", "日 ❯ ls", "x"], from: 0, wrapped: [1])
let found = CommandBlocks.relocate(commands: [("ls", "~/a/long/dir 日 ❯ ls")], in: screen, lastRow: 2, promptLines: 1)
#expect(found.first??.prompt == 0)
}

/// Blocks saved before they kept their command line still load.
@Test func decodesBlocksWithoutTheirCommandLine() throws {
let block = try JSONDecoder().decode(CommandBlocks.Block.self, from: Data(#"{"prompt":4,"output":5}"#.utf8))
#expect(block == .init(prompt: 4, output: 5))
}

@Test func replacesItsBlocks() {
Expand Down
4 changes: 2 additions & 2 deletions Tests/CodecCoreTests/SavedWorkspaceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ struct SavedWorkspaceTests {
var timeline = CommandTimeline()
timeline.begin(command: "old", directory: "/", started: Date(timeIntervalSince1970: 0), block: .init(prompt: 2, output: 3))
timeline.finish(exitCode: 0, duration: 1, output: CapturedOutput(lines: ["gone"]))
timeline.begin(command: "make", directory: "/", started: Date(timeIntervalSince1970: 10), block: .init(prompt: 50, output: 51))
timeline.begin(command: "make", directory: "/", started: Date(timeIntervalSince1970: 10), block: .init(prompt: 50, output: 51, commandLine: "% make"))
timeline.finish(exitCode: 2, duration: 3, output: CapturedOutput(lines: ["error"]))
timeline.begin(command: "npm run dev", directory: "/", started: Date(timeIntervalSince1970: 20), block: .init(prompt: 60, output: 61))

let rebased = timeline.rebased(firstRow: 40)
#expect(rebased.entries.map(\.block) == [nil, .init(prompt: 10, output: 11), .init(prompt: 20, output: 21)])
#expect(rebased.entries.map(\.block) == [nil, .init(prompt: 10, output: 11, commandLine: "% make"), .init(prompt: 20, output: 21)])
// What was running didn't report back.
#expect(rebased.entries.last?.exitCode == -1)
#expect(rebased.entries[1].output?.text == "error")
Expand Down
38 changes: 35 additions & 3 deletions Tests/CodecUITests/SelectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,37 @@ struct ZshSelectionTests {
#expect(selected.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } == ["one", "two", "three"])
}

/// Regression: after a rewrap, a command was found again on the newest line ending with it, and
/// `ls` printing `Tools` ends with it. Its output then started below that line, at the next prompt.
@Test(arguments: ["%# ", "top\n\n%# "])
func copiesTheSameOutputAfterBlocksMove(prompt: String) async throws {
let env = try TestEnvironment(shell: "/bin/zsh", integration: true)
defer { env.tearDown() }
let project = env.directory.appendingPathComponent("project")
try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true)
for name in ["Pictures", "Tools"] {
try "".write(to: project.appendingPathComponent(name), atomically: true, encoding: .utf8)
}
let (pane, window) = try zshPane(env, prompt: prompt, directory: project.path)
defer { pane.shutDown(); window.close() }
let promptLines = prompt.split(separator: "\n", omittingEmptySubsequences: false).count
#expect(await waitUntil { pane.isAtCommandLine && pane.session.promptLines == promptLines })
pane.terminal.send(txt: "ls\r")
#expect(await waitUntil { pane.lastOutputRange != nil && pane.isAtCommandLine })
let output = try #require(pane.lastOutputRange.flatMap(pane.text(of:)))
#expect(output.hasPrefix("Pictures") && output.hasSuffix("Tools"))

// As a resize would.
pane.sizeChanged(source: pane.terminal, newCols: 80, newRows: 24)
let range = try #require(pane.lastOutputRange)
#expect(pane.text(of: range) == output)
let pasteboard = NSPasteboard.general
let saved = pasteboard.string(forType: .string)
defer { pasteboard.clearContents(); if let saved { pasteboard.setString(saved, forType: .string) } }
pane.copyOutput(range)
#expect(pasteboard.string(forType: .string) == output)
}

/// Dragging on the command line selects through zsh only: no second, terminal highlight.
@Test func draggingOnTheCommandLineSelectsInTheShell() async throws {
let env = try TestEnvironment(shell: "/bin/zsh", integration: true)
Expand Down Expand Up @@ -256,9 +287,10 @@ extension OpenListedFileTests {
#expect(link.marks.isEmpty)
}

/// Regression: the prompt's own lines were left out, found from command blocks. After a rewrap a
/// block is found again from the newest line ending with its command, which can be its own output
/// (`wildcat list` for `cat list`), so a line of output went unmarked.
/// Regression: the prompt's own lines were left out, found from command blocks, and a block found
/// again after a rewrap on a line of its own output (`wildcat list` for `cat list`, before blocks
/// remembered their command line) left a line of output unmarked. The marks shouldn't hang on where
/// blocks are.
@Test func everyNameIsMarkedAfterBlocksMove() async throws {
let env = try TestEnvironment(shell: "/bin/zsh", integration: true)
defer { env.tearDown() }
Expand Down
Loading