diff --git a/Sources/CodecCore/CommandBlocks.swift b/Sources/CodecCore/CommandBlocks.swift index a7d9053..fb9c470 100644 --- a/Sources/CodecCore/CommandBlocks.swift +++ b/Sources/CodecCore/CommandBlocks.swift @@ -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 } } @@ -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 } @@ -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 } @@ -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) } @@ -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) } diff --git a/Sources/CodecCore/CommandTimeline.swift b/Sources/CodecCore/CommandTimeline.swift index 2a887a6..7747198 100644 --- a/Sources/CodecCore/CommandTimeline.swift +++ b/Sources/CodecCore/CommandTimeline.swift @@ -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 } diff --git a/Sources/CodecUI/Pane.swift b/Sources/CodecUI/Pane.swift index 74efa26..a619983 100644 --- a/Sources/CodecUI/Pane.swift +++ b/Sources/CodecUI/Pane.swift @@ -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 @@ -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 @@ -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 }) } diff --git a/Tests/CodecCoreTests/CommandBlocksTests.swift b/Tests/CodecCoreTests/CommandBlocksTests.swift index f4d4077..35ce08b 100644 --- a/Tests/CodecCoreTests/CommandBlocksTests.swift +++ b/Tests/CodecCoreTests/CommandBlocksTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CodecCore @@ -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() { @@ -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() { diff --git a/Tests/CodecCoreTests/SavedWorkspaceTests.swift b/Tests/CodecCoreTests/SavedWorkspaceTests.swift index 0cbd92a..2e0514e 100644 --- a/Tests/CodecCoreTests/SavedWorkspaceTests.swift +++ b/Tests/CodecCoreTests/SavedWorkspaceTests.swift @@ -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") diff --git a/Tests/CodecUITests/SelectionTests.swift b/Tests/CodecUITests/SelectionTests.swift index 03509f7..77ef70f 100644 --- a/Tests/CodecUITests/SelectionTests.swift +++ b/Tests/CodecUITests/SelectionTests.swift @@ -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) @@ -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() }