diff --git a/Sources/CodecCore/FileReference.swift b/Sources/CodecCore/FileReference.swift new file mode 100644 index 0000000..120e235 --- /dev/null +++ b/Sources/CodecCore/FileReference.swift @@ -0,0 +1,78 @@ +import Foundation + +/// A file named in a command's output, as ⌘-click finds it: a name `ls` listed, a path `git status` +/// or `grep` printed, a compiler's `file.swift:42:7`. Names are relative to where the command ran, or +/// to a folder it was given, as in `ls Sources`. +public enum FileReference { + /// The existing file or folder `token` names, looking in each of `directories` in turn, or nil. + public static func resolve(_ token: String, in directories: [String], + exists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }) -> String? { + for candidate in candidates(token) { + let paths = candidate.hasPrefix("/") + ? [candidate] + : directories.filter { !$0.isEmpty }.map { ($0 as NSString).appendingPathComponent(candidate) } + if let path = paths.map({ ($0 as NSString).standardizingPath }).first(where: exists) { return path } + } + return nil + } + + /// The folders a command's output names files from: the one it ran in, then the folders among its + /// arguments. + public static func directories(command: String, in directory: String, + isDirectory: (String) -> Bool = { FileReference.isDirectory($0) }) -> [String] { + guard !directory.isEmpty else { return [] } + let line = command.split(whereSeparator: \.isNewline).first.map(String.init) ?? "" + var directories = [directory] + // Words, split at the operators that end them too, as in `ls Sources; ls Tests`. + let words = line.split { $0.isWhitespace || ";&|()<>".contains($0) } + for word in words.dropFirst() where !word.hasPrefix("-") { + let path = (unquoted(String(word)) as NSString).expandingTildeInPath + guard !path.isEmpty else { continue } + let full = path.hasPrefix("/") ? path : (directory as NSString).appendingPathComponent(path) + let standardized = (full as NSString).standardizingPath + if isDirectory(standardized), !directories.contains(standardized) { directories.append(standardized) } + } + return directories + } + + /// How to show the path a name opens: relative to the shell's directory when it's inside it, else + /// from `~`. Nil when that's just what the name says, as for `README.md` listed where you are. + public static func label(for path: String, named name: String, from directory: String, + home: String = NSHomeDirectory()) -> String? { + let base = directory.hasSuffix("/") ? directory : directory + "/" + var label = path + if !directory.isEmpty, path.hasPrefix(base) { + label = String(path.dropFirst(base.count)) + } else if path == home || path.hasPrefix(home + "/") { + label = "~" + path.dropFirst(home.count) + } + var said = name + if said.hasPrefix("./") { said.removeFirst(2) } + while said.count > 1, said.hasSuffix("/") { said.removeLast() } + return label == said ? nil : label + } + + public static func isDirectory(_ path: String) -> Bool { + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + } + + /// What a token may name, most literal first: itself, then without a `:line` or `:line:column` + /// after it, then without the mark `ls -F` puts after executables (`*`), links (`@`) and sockets (`=`). + static func candidates(_ token: String) -> [String] { + guard !token.isEmpty, !token.contains("://") else { return [] } + var candidates = [token] + if let location = token.range(of: #":[0-9]+(:[0-9]+)?$"#, options: .regularExpression) { + candidates.append(String(token[.. NSPoint? = { [weak self] in self?.terminal.window?.mouseLocationOutsideOfEventStream } /// A pane with a shell in `directory`, showing first what it showed last time when there's `contents`. init(directory: String?, environment: AppEnvironment, restoring contents: SavedPaneContents? = nil) { @@ -61,15 +67,48 @@ final class Pane: LocalProcessTerminalViewDelegate { terminal.contextMenuProvider = { [weak self] event in self?.contextMenu(at: event.locationInWindow) } terminal.shouldOwnPress = { [weak self] event in guard let self else { return false } - return self.pressBranch(event) || self.beginCommandLineGesture(event) + return self.pressFile(event) || self.pressBranch(event) || self.beginCommandLineGesture(event) } terminal.onOwnedDrag = { [weak self] event in self?.continueCommandLineGesture(event) } terminal.onOwnedRelease = { [weak self] _ in guard let self else { return } - if self.pressedBranch { self.pressedBranch = false } else { self.endCommandLineGesture() } + if let file = self.pressedFile { + self.pressedFile = nil + self.view.fileLink.isPressed = false + self.host?.openFile(file, from: self) + } else if self.pressedBranch { + self.pressedBranch = false + } else { + self.endCommandLineGesture() + } } terminal.addSubview(branchHotspot) terminal.onDropFiles = { [weak self] urls in self?.insertPaths(urls) } + // SwiftTerm doesn't let a subclass see ⌘ go down or the pointer move; a monitor can. While ⌘ + // is held, SwiftTerm has the window report every move. Letting go of ⌘ counts in any window, so + // the line never outlasts it. + fileLinkMonitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged, .mouseMoved]) { [weak self] event in + MainActor.assumeIsolated { + guard let self, event.window === self.terminal.window || !event.modifierFlags.contains(.command) else { return } + self.refreshFileLink(modifiers: event.modifierFlags) + } + return event + } + // ⌘ can be let go where Codec doesn't hear it: in Finder after ⌘-clicking a folder, or after ⌘⇥. + fileLinkObservers = [NSApplication.didResignActiveNotification, NSWindow.didResignKeyNotification].map { name in + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in + let sender = (note.object as AnyObject?).map(ObjectIdentifier.init) + MainActor.assumeIsolated { + guard let self, name == NSApplication.didResignActiveNotification + || sender == self.terminal.window.map(ObjectIdentifier.init) else { return } + self.hideFileLink(pointer: nil) + } + } + } + view.onScroll = { [weak self] in + guard let self, self.openableNames != nil else { return } + self.refreshFileLink(modifiers: self.fileLinkModifiers, rescan: true) + } terminal.onRelease = { [weak self] clicks, dragged, point in self?.terminalGestureEnded(clicks: clicks, dragged: dragged, at: point) } @@ -230,6 +269,10 @@ final class Pane: LocalProcessTerminalViewDelegate { oscObservation?.cancel() promptRefresh?.cancel() branchPopover?.close() + if let fileLinkMonitor { NSEvent.removeMonitor(fileLinkMonitor) } + fileLinkMonitor = nil + fileLinkObservers.forEach(NotificationCenter.default.removeObserver) + fileLinkObservers = [] if terminal.process.running { terminal.terminate() } environment.notifier.clear(id: session.id.uuidString) } @@ -263,6 +306,7 @@ final class Pane: LocalProcessTerminalViewDelegate { session.unseenOutput = true } if printed { view.finder.contentChanged() } + if printed, openableNames != nil { refreshFileLink(modifiers: fileLinkModifiers, rescan: true) } ticks += 1 if ticks % 4 == 0 { pollProcess() } if session.running != nil, timeline.running?.fullScreen == false, @@ -492,16 +536,175 @@ final class Pane: LocalProcessTerminalViewDelegate { /// Selects the path, URL, email, `host:port`… under a point, where the terminal's own word /// selection stops at punctuation. func selectWholeToken(at point: NSPoint) { - guard let grid = TerminalGrid(terminal: terminal) else { return } + guard let grid = TerminalGrid(terminal: terminal), let token = token(at: point, in: grid), + terminal.getSelection() != token.text else { return } + terminal.selectByDragging(from: grid.center(row: token.first.row, column: token.first.column), + to: grid.trailingEdge(row: token.last.row, column: token.last.column)) + } + + /// The token under a window point, as a double-click selects it, with its first and last cells. + private func token(at point: NSPoint, in grid: TerminalGrid) + -> (text: String, first: (row: Int, column: Int), last: (row: Int, column: Int))? { let cell = grid.cell(at: point) guard let line = grid.logicalLine(containing: cell.row), let index = line.cells.firstIndex(where: { $0.row == cell.row && $0.column == cell.column }), - let range = SmartSelection.range(in: line.characters, at: index) else { return } - let token = String(line.characters[range]) - guard terminal.getSelection() != token else { return } - let first = line.cells[range.lowerBound], last = line.cells[range.upperBound - 1] - terminal.selectByDragging(from: grid.center(row: first.row, column: first.column), - to: grid.trailingEdge(row: last.row, column: last.column)) + let range = SmartSelection.range(in: line.characters, at: index) else { return nil } + return (String(line.characters[range]), line.cells[range.lowerBound], line.cells[range.upperBound - 1]) + } + + // MARK: - Opening files + + /// A file or folder ⌘-pressed, opened when the press ends. + private var pressedFile: URL? + + /// Takes a ⌘-click on the name of a file or folder that exists, as `ls` lists it or a compiler + /// reports it. Anything else, URLs included, the terminal handles. + func pressFile(_ event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, event.clickCount == 1, + event.modifierFlags.intersection([.shift, .option, .command, .control]) == .command, + terminal.currentMouseMode == .off, + let path = file(at: event.locationInWindow) else { return false } + pressedFile = URL(fileURLWithPath: path) + view.fileLink.isPressed = true + return true + } + + /// The file or folder named at a window point. Its name is relative to where the command that + /// printed it ran, or to a folder the command was given (`ls Sources`); on the command line being + /// typed, to the shell's directory. + func file(at point: NSPoint) -> String? { + guard let grid = TerminalGrid(terminal: terminal), let token = token(at: point, in: grid) else { return nil } + return file(named: token.text, at: grid.viewportTop + token.first.row) + } + + private func file(named name: String, at row: Int) -> String? { + let entry = newestBlockEnd.map { row < $0 } == true ? command(atRow: row) : nil + let directories = FileReference.directories(command: entry?.command ?? "", in: entry?.directory ?? session.directory) + return FileReference.resolve(name, in: directories) + } + + /// A name on screen that a ⌘-click would open. + private struct OpenableName { + var text: String + var first: (row: Int, column: Int) + var last: (row: Int, column: Int) + var path: String + + func contains(_ cell: (row: Int, column: Int)) -> Bool { + (cell.row, cell.column) >= (first.row, first.column) && (cell.row, cell.column) <= (last.row, last.column) + } + } + + /// Every name on screen ⌘ would open, found when ⌘ went down and again when the screen changes, + /// so moving the pointer doesn't ask the disk. Nil while ⌘ is up. + private var openableNames: [OpenableName]? + private var isPointingAtFile = false + /// The modifiers when the marks were last drawn, to redraw them with when the screen changes. + private var fileLinkModifiers: NSEvent.ModifierFlags = [] + + /// Marks what ⌘ would open, if ⌘ is down, or takes the marks away. + func refreshFileLink(modifiers: NSEvent.ModifierFlags, rescan: Bool = false) { + guard let point = pointerLocation() else { return hideFileLink(pointer: nil) } + updateFileLink(at: point, modifiers: modifiers, rescan: rescan) + } + + /// While ⌘ is down, marks every name on screen a click would open, and the one under the pointer + /// most, with a tag saying what opens and the pointing hand. Otherwise takes the marks away. + func updateFileLink(at point: NSPoint, modifiers: NSEvent.ModifierFlags, rescan: Bool = false) { + guard !isShutDown, modifiers.intersection([.shift, .option, .command, .control]) == .command, + terminal.currentMouseMode == .off, terminal.window != nil, + let grid = TerminalGrid(terminal: terminal) else { + return hideFileLink(pointer: point) + } + fileLinkModifiers = modifiers + if rescan || openableNames == nil { openableNames = openableNames(in: grid) } + let names = openableNames ?? [] + let cell = grid.cell(at: point) + let hovered = terminal.bounds.contains(terminal.convert(point, from: nil)) + ? names.firstIndex { $0.contains(cell) } : nil + let link = view.fileLink + link.font = terminal.font + func frames(_ name: OpenableName) -> [NSRect] { + (name.first.row...name.last.row).map { row in + let start = row == name.first.row ? name.first.column : 0 + let end = row == name.last.row ? name.last.column + 1 : grid.columns + return link.convert(grid.frame(row: row, columns: start.. [OpenableName] { + relocateBlocksIfNeeded() + let end = newestBlockEnd + var known: [Int: [String]] = [:] + func directories(forRow row: Int) -> [String] { + let index = end.map { row < $0 } == true ? blocks.index(containing: row) : nil + if let directories = known[index ?? -1] { return directories } + let entry = index.flatMap { index in timeline.entries.last { $0.block == blocks.blocks[index] } } + let found = FileReference.directories(command: entry?.command ?? "", in: entry?.directory ?? session.directory) + known[index ?? -1] = found + return found + } + var names: [OpenableName] = [] + var row = 0 + // A screen full of names is a few hundred; past that, it's not worth asking the disk about more. + while row < grid.rows.count, names.count < 400 { + guard let line = grid.logicalLine(containing: row) else { break } + defer { row = max(row + 1, (line.cells.last?.row ?? row) + 1) } + var index = 0 + while index < line.characters.count { + guard let range = SmartSelection.range(in: line.characters, at: index) else { + index += 1 + continue + } + let text = String(line.characters[range]) + let first = line.cells[range.lowerBound], last = line.cells[range.upperBound - 1] + if let path = FileReference.resolve(text, in: directories(forRow: grid.viewportTop + first.row)) { + names.append(OpenableName(text: text, first: first, last: last, path: path)) + } + index = range.upperBound + } + } + return names + } + + /// What the tag beside the name under the pointer shows: the file's icon, and the path that opens + /// when it's more than the name says. + private func fileLinkTag(_ path: String, named name: String) -> FileLinkView.Tag { + let file = (path as NSString).lastPathComponent + let theme = preferences.theme + let icon = FileReference.isDirectory(path) + ? FileIcons.image(for: FolderType.of(folderNamed: file), state: .closed, theme: theme) + : FileIcons.image(for: FileType.of(fileNamed: file), theme: theme) + return FileLinkView.Tag(icon: icon, path: FileReference.label(for: path, named: name, from: session.directory)) + } + + private func hideFileLink(pointer point: NSPoint?) { + openableNames = nil + guard view.fileLink.isShowing else { return } + view.fileLink.show(marks: [], frames: [], tag: nil) + if isPointingAtFile, let point, terminal.bounds.contains(terminal.convert(point, from: nil)) { NSCursor.iBeam.set() } + isPointingAtFile = false + } + + /// The command, running or finished, whose prompt or output holds an absolute row. + private func command(atRow row: Int) -> TimelineEntry? { + relocateBlocksIfNeeded() + guard let index = blocks.index(containing: row) else { return nil } + let block = blocks.blocks[index] + return timeline.entries.last { $0.block == block } } // MARK: - Command output diff --git a/Sources/CodecUI/PaneView.swift b/Sources/CodecUI/PaneView.swift index 3bf8688..de852f8 100644 --- a/Sources/CodecUI/PaneView.swift +++ b/Sources/CodecUI/PaneView.swift @@ -2,13 +2,15 @@ import AppKit import SwiftUI import CodecCore -/// The terminal plus the pane's overlays: find bar and highlights, bell indicator, exit banner and the -/// inactive-pane dimmer. +/// The terminal plus the pane's overlays: find bar and highlights, the file ⌘-click would open, bell +/// indicator, exit banner and the inactive-pane dimmer. final class PaneView: NSView { let terminal = CodecTerminalView(frame: .zero) let scanlines = ScanlineView() /// Marks a command the user went back to. let commandMark = CommandMarkView() + /// Underlines the file name under the pointer while ⌘ is held. + let fileLink = FileLinkView() /// Find in the terminal: its bar and its highlights. private(set) lazy var finder = TerminalFinder(terminal: terminal) let overlay: PassthroughHostingView @@ -19,6 +21,8 @@ final class PaneView: NSView { var onRestart: (() -> Void)? { didSet { keyCatcher.onReturn = onRestart } } + /// The terminal scrolled, by the user or as output came in. + var onScroll: (() -> Void)? var onDismiss: (() -> Void)? { didSet { keyCatcher.onEscape = onDismiss } } @@ -37,9 +41,12 @@ final class PaneView: NSView { let barRoom = finder.bar.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 14) barRoom.priority = .defaultLow terminal.onFind = { [weak self] action in self?.finder.perform(action) } - terminal.onScroll = { [weak self] in self?.finder.refreshHighlights() } + terminal.onScroll = { [weak self] in + self?.finder.refreshHighlights() + self?.onScroll?() + } - for view in [terminal, finder.highlights, scanlines, commandMark, finder.bar, dimmer, overlay, keyCatcher] as [NSView] { + for view in [terminal, finder.highlights, fileLink, scanlines, commandMark, finder.bar, dimmer, overlay, keyCatcher] as [NSView] { view.translatesAutoresizingMaskIntoConstraints = false addSubview(view) } @@ -55,7 +62,7 @@ final class PaneView: NSView { finder.bar.topAnchor.constraint(equalTo: topAnchor, constant: 8), finder.bar.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), barRoom, - ] + [finder.highlights, scanlines, commandMark, dimmer, overlay].flatMap { $0.constraintsFilling(self) }) + ] + [finder.highlights, fileLink, scanlines, commandMark, dimmer, overlay].flatMap { $0.constraintsFilling(self) }) } override func layout() { @@ -79,6 +86,7 @@ final class PaneView: NSView { func apply(theme: CodecTheme, scanlines enabled: Bool) { finder.apply(theme: theme) + fileLink.theme = theme scanlines.isHidden = !enabled scanlines.lineColor = theme.isDark ? .black : NSColor(white: 0, alpha: 0.5) scanlines.needsDisplay = true @@ -157,6 +165,133 @@ final class PassthroughView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { nil } } +/// What ⌘ would open. Every name a click would open is washed in the accent, like a prompt chip; the +/// one under the pointer more, and underlined, with a tag beside it showing the file's icon and, when +/// the name doesn't say it all, the path that opens. The tag goes under the name when there's no room +/// beside it. +final class FileLinkView: NSView { + struct Tag: Equatable { + var icon: NSImage + var path: String? + } + + var theme: CodecTheme = .codec { + didSet { needsDisplay = true } + } + /// The terminal's font; the tag's path is set in it, a little smaller. + var font: NSFont = .monospacedSystemFont(ofSize: 13, weight: .regular) + /// The other names' cells, a rect per row each spans, in this view's coordinates. + private(set) var marks: [NSRect] = [] + /// The cells of the name under the pointer. + private(set) var frames: [NSRect] = [] + private(set) var fileTag: Tag? + /// Where the tag is, in this view's coordinates. + private(set) var tagFrame: NSRect = .zero + /// A ⌘-press is on the name: its wash deepens until the press ends. + var isPressed = false { + didSet { if isPressed != oldValue { needsDisplay = true } } + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + var isShowing: Bool { !marks.isEmpty || !frames.isEmpty } + + func show(marks: [NSRect], frames: [NSRect], tag: Tag?) { + guard marks != self.marks || frames != self.frames || tag != fileTag else { return } + let appearing = !isShowing && (!marks.isEmpty || !frames.isEmpty) + self.marks = marks + self.frames = frames + fileTag = frames.isEmpty ? nil : tag + if frames.isEmpty { isPressed = false } + layOutTag() + needsDisplay = true + // It comes in quickly when ⌘ goes down, and goes at once when ⌘ comes up. + if appearing, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { + alphaValue = 0 + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.09 + animator().alphaValue = 1 + } + } else if !isShowing { + alphaValue = 1 + } + } + + override func layout() { + super.layout() + layOutTag() + } + + private var tagFont: NSFont { + NSFont(descriptor: font.fontDescriptor, size: (font.pointSize * 0.85).rounded()) ?? font + } + + /// The path, its folders bright since they're what the name didn't say, and its last part, which + /// the name already shows, quieter. + private var tagText: NSAttributedString? { + guard let path = fileTag?.path else { return nil } + let paragraph = NSMutableParagraphStyle() + // A long path loses its start, never the file's name. + paragraph.lineBreakMode = .byTruncatingHead + let split = path.range(of: "/", options: .backwards).map(\.upperBound) ?? path.startIndex + let text = NSMutableAttributedString() + for (part, alpha) in [(path[.. CGFloat { min(FileIcons.size.height, height - 4) } + + private func layOutTag() { + guard fileTag != nil, let first = frames.first, let last = frames.last else { + tagFrame = .zero + return + } + let height = last.height - 1 + let text = tagText.map { ceil($0.size().width) } + let width = min(5 + iconSide(height) + (text.map { 5 + $0 + 7 } ?? 5), bounds.width - 8) + var frame = NSRect(x: last.maxX + 6, y: last.midY - height / 2, width: width, height: height) + if frame.maxX > bounds.maxX - 4 { + frame.origin.x = max(4, min(first.minX - 2, bounds.maxX - 4 - width)) + frame.origin.y = last.minY - height - 3 + if frame.minY < bounds.minY { frame.origin.y = first.maxY + 3 } + } + tagFrame = frame + } + + override func draw(_ dirtyRect: NSRect) { + let accent = theme.accent + accent.withAlphaComponent(0.13).setFill() + for mark in marks { + NSBezierPath(roundedRect: mark.insetBy(dx: -2, dy: 0.5), xRadius: 3, yRadius: 3).fill() + } + for frame in frames { + accent.withAlphaComponent(isPressed ? 0.4 : 0.26).setFill() + NSBezierPath(roundedRect: frame.insetBy(dx: -2, dy: 0.5), xRadius: 3, yRadius: 3).fill() + accent.setFill() + NSRect(x: frame.minX, y: frame.minY + 1.5, width: frame.width, height: 1.5).fill() + } + guard let fileTag, !tagFrame.isEmpty else { return } + let chip = NSBezierPath(roundedRect: tagFrame.insetBy(dx: 0.5, dy: 0.5), xRadius: 4, yRadius: 4) + (theme.background.blended(withFraction: 0.12, of: accent) ?? theme.background).setFill() + chip.fill() + accent.withAlphaComponent(0.28).setStroke() + chip.lineWidth = 1 + chip.stroke() + let side = iconSide(tagFrame.height) + let icon = NSRect(x: tagFrame.minX + 5, y: (tagFrame.midY - side / 2).rounded(), width: side, height: side) + fileTag.icon.draw(in: icon) + guard let text = tagText else { return } + let lineHeight = ceil(tagFont.ascender - tagFont.descender) + text.draw(with: NSRect(x: icon.maxX + 5, y: (tagFrame.midY - lineHeight / 2).rounded(), + width: tagFrame.maxX - 7 - (icon.maxX + 5), height: lineHeight), + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine]) + } +} + /// The mark over a command the user went back to; it fades after a moment. final class CommandMarkView: NSView { private let bar = PassthroughView() diff --git a/Sources/CodecUI/TerminalWindowController.swift b/Sources/CodecUI/TerminalWindowController.swift index 6dc2a29..df0e338 100644 --- a/Sources/CodecUI/TerminalWindowController.swift +++ b/Sources/CodecUI/TerminalWindowController.swift @@ -916,6 +916,14 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe remove(pane) } + func openFile(_ url: URL, from pane: Pane) { + if FileReference.isDirectory(url.path) { + NSWorkspace.shared.open(url) + } else { + openFile(url, pin: true, focus: false) + } + } + // MARK: - History private var historyPopover: NSPopover? diff --git a/Tests/CodecCoreTests/FileReferenceTests.swift b/Tests/CodecCoreTests/FileReferenceTests.swift new file mode 100644 index 0000000..53ec2fb --- /dev/null +++ b/Tests/CodecCoreTests/FileReferenceTests.swift @@ -0,0 +1,57 @@ +import Testing +@testable import CodecCore + +@Suite("FileReference") +struct FileReferenceTests { + private let files: Set = ["/p/README.md", "/p/Sources", "/p/Sources/main.swift", "/p/run.sh", "/home/notes.txt"] + + private func resolve(_ token: String, in directories: [String] = ["/p"]) -> String? { + FileReference.resolve(token, in: directories, exists: files.contains) + } + + @Test(arguments: [ + ("README.md", "/p/README.md"), + ("Sources/", "/p/Sources"), + ("./Sources/main.swift", "/p/Sources/main.swift"), + ("Sources/main.swift:42:7", "/p/Sources/main.swift"), + ("Sources/main.swift:42", "/p/Sources/main.swift"), + ("run.sh*", "/p/run.sh"), + ("/home/notes.txt", "/home/notes.txt"), + ]) + func findsFilesRelativeToTheDirectory(token: String, expected: String) { + #expect(resolve(token) == expected) + } + + @Test func leavesWhatIsntAFileAlone() { + #expect(resolve("total") == nil) + #expect(resolve("https://p/README.md") == nil) + #expect(resolve("") == nil) + #expect(resolve("README.md", in: [""]) == nil) + } + + @Test func looksInEachDirectoryInTurn() { + #expect(resolve("main.swift", in: ["/p", "/p/Sources"]) == "/p/Sources/main.swift") + #expect(resolve("main.swift") == nil) + } + + @Test func labelsThePathOnlyWhenTheNameDoesntSayIt() { + #expect(FileReference.label(for: "/p/README.md", named: "README.md", from: "/p") == nil) + #expect(FileReference.label(for: "/p/Sources", named: "Sources/", from: "/p/") == nil) + #expect(FileReference.label(for: "/p/run.sh", named: "./run.sh", from: "/p") == nil) + #expect(FileReference.label(for: "/p/Sources/main.swift", named: "main.swift", from: "/p") == "Sources/main.swift") + #expect(FileReference.label(for: "/p/main.swift", named: "main.swift:42:7", from: "/p") == "main.swift") + #expect(FileReference.label(for: "/home/me/notes.txt", named: "notes.txt", from: "/p", home: "/home/me") == "~/notes.txt") + #expect(FileReference.label(for: "/etc/hosts", named: "hosts", from: "/p", home: "/home/me") == "/etc/hosts") + } + + @Test func aCommandsDirectoriesAreWhereItRanThenTheFoldersItWasGiven() { + let folders: Set = ["/p", "/p/Sources", "/p/Tests", "/etc"] + let directories = FileReference.directories(command: "ls -la Sources 'Tests' /etc README.md ../p/Sources", + in: "/p", isDirectory: folders.contains) + #expect(directories == ["/p", "/p/Sources", "/p/Tests", "/etc"]) + #expect(FileReference.directories(command: "ls Sources; ls Tests && cd /etc", in: "/p", + isDirectory: folders.contains) == ["/p", "/p/Sources", "/p/Tests", "/etc"]) + #expect(FileReference.directories(command: "", in: "/p", isDirectory: folders.contains) == ["/p"]) + #expect(FileReference.directories(command: "ls", in: "", isDirectory: folders.contains).isEmpty) + } +} diff --git a/Tests/CodecUITests/ScreenshotTour.swift b/Tests/CodecUITests/ScreenshotTour.swift index acf9f2c..0a4cf55 100644 --- a/Tests/CodecUITests/ScreenshotTour.swift +++ b/Tests/CodecUITests/ScreenshotTour.swift @@ -47,6 +47,7 @@ struct ScreenshotTour { try await intel(env, project: project) try await history(env, project: project) try await editor(env, project: project) + try await openFile(env, project: project) try await branches(env, project: project) try await themes(env, project: project) try await usualDuration(env, project: project) @@ -132,6 +133,38 @@ struct ScreenshotTour { try await capture(controller.window!, as: "editor") } + /// ⌘ held down: every name a click would open marked, and the one under the pointer tagged with + /// the file that opens. + private func openFile(_ env: TestEnvironment, project: URL) async throws { + let (controller, pane) = try await window(env, project: project, size: NSSize(width: 900, height: 480)) + defer { close(controller) } + try await run("ls", in: pane) + try await run("ls Sources/CodecCore | head -8", in: pane) + try point(pane, at: "FileTree.swift") + pane.refreshFileLink(modifiers: .command) + try await settle(seconds: 0.5) + try await capture(controller.window!, as: "open-file") + } + + /// Puts the pointer on the last `name` on screen, as the terminal lays out its cells. + private func point(_ pane: Pane, at name: String) throws { + let grid = try #require(TerminalGrid(terminal: pane.terminal)) + for row in grid.rows.indices.reversed() { + let cells = grid.cells(ofRow: row) + let characters = cells.map(\.character) + let target = Array(name) + guard characters.count >= target.count else { continue } + for start in (0...(characters.count - target.count)).reversed() + where Array(characters[start..<(start + target.count)]) == target + && (start == 0 || characters[start - 1] == " ") { + let point = grid.center(row: row, column: cells[start + 1].column) + pane.pointerLocation = { point } + return + } + } + Issue.record("\(name) isn't on screen") + } + /// Two panes, and the branch switcher open on the status bar's branch. private func branches(_ env: TestEnvironment, project: URL) async throws { let (controller, pane) = try await window(env, project: project, size: NSSize(width: 1180, height: 720)) diff --git a/Tests/CodecUITests/SelectionTests.swift b/Tests/CodecUITests/SelectionTests.swift index ee10f14..03509f7 100644 --- a/Tests/CodecUITests/SelectionTests.swift +++ b/Tests/CodecUITests/SelectionTests.swift @@ -145,6 +145,197 @@ struct ZshSelectionTests { } } +/// ⌘-click on a file's name in the output opens it. +@Suite("Opening listed files", .serialized, .timeLimit(.minutes(1)), + .enabled(if: FileManager.default.isExecutableFile(atPath: "/bin/zsh"))) +@MainActor +struct OpenListedFileTests { + private func zshPane(_ env: TestEnvironment, host: FakeHost, directory: String) throws -> (Pane, NSWindow) { + let config = env.directory.appendingPathComponent("zdotdir") + try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true) + try "PS1='%# '\n".write(to: config.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) + setenv("ZDOTDIR", config.path, 1) + defer { unsetenv("ZDOTDIR") } + let pane = env.makePane(directory: directory, host: host) + let window = makeTestWindow(size: NSSize(width: 800, height: 400)) + window.contentView = pane.view + pane.view.layoutSubtreeIfNeeded() + return (pane, window) + } + + private func click(_ pane: Pane, at point: NSPoint, in window: NSWindow, modifiers: NSEvent.ModifierFlags) { + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1)! + if type == .leftMouseDown { pane.terminal.mouseDown(with: event) } else { pane.terminal.mouseUp(with: event) } + } + } + + /// The center of the last cell showing `target` on screen, which is output rather than the command typed. + private func point(of target: String, in pane: Pane) throws -> NSPoint { + let grid = try #require(TerminalGrid(terminal: pane.terminal)) + for (row, snapshot) in grid.rows.enumerated().reversed() { + if let range = snapshot.text.range(of: target, options: .backwards) { + return grid.center(row: row, column: snapshot.text.distance(from: snapshot.text.startIndex, to: range.lowerBound)) + } + } + Issue.record("\(target) isn't on screen") + return .zero + } + + @Test func commandClickOpensAFileListedFromAFolderTheCommandWasGiven() async throws { + let env = try TestEnvironment(shell: "/bin/zsh", integration: true) + defer { env.tearDown() } + let project = env.directory.appendingPathComponent("project") + let sources = project.appendingPathComponent("Sources") + try FileManager.default.createDirectory(at: sources, withIntermediateDirectories: true) + try "".write(to: sources.appendingPathComponent("listed.swift"), atomically: true, encoding: .utf8) + let host = FakeHost() + let (pane, window) = try zshPane(env, host: host, directory: project.path) + defer { pane.shutDown(); window.close() } + #expect(await waitUntil { pane.isAtCommandLine }) + pane.terminal.send(txt: "ls Sources; echo done-listing\r") + #expect(await waitUntil { pane.lastOutputRange != nil && pane.isAtCommandLine }) + #expect(await waitUntil { pane.terminal.visibleRowsText(0..<10).joined().contains("done-listing") }) + + let name = try point(of: "listed.swift", in: pane) + // A plain click selects as usual; it takes ⌘ to open. + click(pane, at: name, in: window, modifiers: []) + #expect(host.openedFiles.isEmpty) + click(pane, at: name, in: window, modifiers: .command) + #expect(host.openedFiles.map { $0.resolvingSymlinksInPath().path } + == [sources.appendingPathComponent("listed.swift").resolvingSymlinksInPath().path]) + + // A word that names no file is left to the terminal. + click(pane, at: try point(of: "done-listing", in: pane), in: window, modifiers: .command) + #expect(host.openedFiles.count == 1) + } +} + +extension OpenListedFileTests { + @Test func holdingCommandOverAFileUnderlinesIt() 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) + try "".write(to: project.appendingPathComponent("hovered.txt"), atomically: true, encoding: .utf8) + try "".write(to: project.appendingPathComponent("other.txt"), atomically: true, encoding: .utf8) + let host = FakeHost() + let (pane, window) = try zshPane(env, host: host, directory: project.path) + defer { pane.shutDown(); window.close() } + #expect(await waitUntil { pane.isAtCommandLine }) + pane.terminal.send(txt: "ls; echo done-listing\r") + #expect(await waitUntil { pane.lastOutputRange != nil && pane.isAtCommandLine }) + #expect(await waitUntil { pane.terminal.visibleRowsText(0..<10).joined().contains("done-listing") }) + + let name = try point(of: "hovered.txt", in: pane) + let link = pane.view.fileLink + pane.updateFileLink(at: name, modifiers: []) + #expect(link.frames.isEmpty) + + pane.updateFileLink(at: name, modifiers: .command) + let grid = try #require(TerminalGrid(terminal: pane.terminal)) + let cell = grid.cell(at: name) + #expect(link.frames == [link.convert(grid.frame(row: cell.row, columns: cell.column..<(cell.column + 11)), from: nil)]) + #expect(link.fileTag != nil) + // The other file is marked too, less. + let other = grid.cell(at: try point(of: "other.txt", in: pane)) + #expect(link.marks == [link.convert(grid.frame(row: other.row, columns: other.column..<(other.column + 9)), from: nil)]) + + // Moving onto a word that isn't a file leaves both marked, neither under the pointer; letting + // go of ⌘ takes every mark away. + pane.updateFileLink(at: try point(of: "done-listing", in: pane), modifiers: .command) + #expect(link.frames.isEmpty) + #expect(link.marks.count == 2) + #expect(link.fileTag == nil) + pane.updateFileLink(at: name, modifiers: .command) + #expect(!link.frames.isEmpty) + pane.updateFileLink(at: name, modifiers: []) + #expect(link.frames.isEmpty) + #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. + @Test func everyNameIsMarkedAfterBlocksMove() 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) + try "first.txt\nwildcat list\n".write(to: project.appendingPathComponent("list"), atomically: true, encoding: .utf8) + try "".write(to: project.appendingPathComponent("first.txt"), atomically: true, encoding: .utf8) + let config = env.directory.appendingPathComponent("zdotdir") + try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true) + try "PS1=$'top\\n\\n%# '\n".write(to: config.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) + setenv("ZDOTDIR", config.path, 1) + let host = FakeHost() + let pane = env.makePane(directory: project.path, host: host) + unsetenv("ZDOTDIR") + let window = makeTestWindow(size: NSSize(width: 800, height: 400)) + window.contentView = pane.view + pane.view.layoutSubtreeIfNeeded() + defer { pane.shutDown(); window.close() } + #expect(await waitUntil { pane.isAtCommandLine && pane.session.promptLines == 3 }) + pane.terminal.send(txt: "cat list\r") + #expect(await waitUntil { pane.lastOutputRange != nil && pane.isAtCommandLine }) + #expect(await waitUntil { pane.terminal.visibleRowsText(0..<12).joined().contains("wildcat list") }) + + // As a resize would. + pane.sizeChanged(source: pane.terminal, newCols: 80, newRows: 24) + pane.updateFileLink(at: try point(of: "wildcat", in: pane), modifiers: .command) + let grid = try #require(TerminalGrid(terminal: pane.terminal)) + let first = grid.cell(at: try point(of: "first.txt", in: pane)) + #expect(pane.view.fileLink.marks.contains(pane.view.fileLink.convert( + grid.frame(row: first.row, columns: first.column..<(first.column + 9)), from: nil))) + } + + private func modifiersChanged(to flags: NSEvent.ModifierFlags, in window: NSWindow) -> NSEvent { + NSEvent.keyEvent(with: .flagsChanged, location: .zero, modifierFlags: flags, + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, + characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 55)! + } + + /// The line comes with ⌘ going down, without the pointer moving, and goes with ⌘ coming up, + /// wherever that happens. + @Test func theLineLastsAsLongAsCommandIsDown() 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) + try "".write(to: project.appendingPathComponent("pressed.txt"), atomically: true, encoding: .utf8) + let host = FakeHost() + let (pane, window) = try zshPane(env, host: host, directory: project.path) + let other = makeTestWindow() + defer { pane.shutDown(); window.close(); other.close() } + #expect(await waitUntil { pane.isAtCommandLine }) + pane.terminal.send(txt: "ls; echo done-listing\r") + #expect(await waitUntil { pane.lastOutputRange != nil && pane.isAtCommandLine }) + #expect(await waitUntil { pane.terminal.visibleRowsText(0..<10).joined().contains("done-listing") }) + let name = try point(of: "pressed.txt", in: pane) + pane.pointerLocation = { name } + let link = pane.view.fileLink + + NSApp.sendEvent(modifiersChanged(to: .command, in: window)) + #expect(!link.frames.isEmpty) + NSApp.sendEvent(modifiersChanged(to: [], in: window)) + #expect(link.frames.isEmpty) + + // Let go in another window. + NSApp.sendEvent(modifiersChanged(to: .command, in: window)) + #expect(!link.frames.isEmpty) + NSApp.sendEvent(modifiersChanged(to: [], in: other)) + #expect(link.frames.isEmpty) + + // Let go in another app, after Codec stopped being the active one. + NSApp.sendEvent(modifiersChanged(to: .command, in: window)) + #expect(!link.frames.isEmpty) + NotificationCenter.default.post(name: NSApplication.didResignActiveNotification, object: NSApp) + #expect(link.frames.isEmpty) + } +} + /// The branch in the prompt opens the branch picker. @Suite("Branch in the prompt", .serialized, .timeLimit(.minutes(1)), .enabled(if: FileManager.default.isExecutableFile(atPath: "/bin/zsh"))) diff --git a/Tests/CodecUITests/TestSupport.swift b/Tests/CodecUITests/TestSupport.swift index d0bcfdc..6625eb3 100644 --- a/Tests/CodecUITests/TestSupport.swift +++ b/Tests/CodecUITests/TestSupport.swift @@ -42,6 +42,8 @@ final class FakeHost: PaneHost { } private(set) var explained: [TimelineEntry] = [] 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) } } /// Never reports anything, so tests don't depend on the machine's load. diff --git a/docs/command-line.md b/docs/command-line.md index 6c209b2..c37a882 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -19,6 +19,14 @@ zsh's own selection (the region) is what's highlighted, in the theme's selection - **Triple-click** selects the line. - **⌥-drag** selects a rectangular block. +## Opening files and links + +⌘-click a file's name in the output to open it in the [editor](files-and-editor.md#editor), or a folder's to open it in Finder. It works on what `ls` lists, the paths `git status` and `grep` print, and a compiler's `Sources/App/Main.swift:42:7`. Codec looks for the name where the command that printed it ran, then in the folders it was given, so after `ls Sources` its files open too; on the command line you're typing, in the shell's directory. ⌘-click a URL to open it in the browser. + +Hold ⌘ to see what opens. Every file and folder name on screen is washed in the theme's accent color, and the one under the pointer is underlined, with a tag showing its icon and, when the name doesn't say it all, the path that opens (`Sources/CodecCore/FileTree.swift` for a name `ls Sources/CodecCore` listed). The marks go as soon as you let go of ⌘. Names that aren't files are left alone. + +![⌘ held over the output of ls: every name marked, one tagged with the file that opens](images/open-file.png) + Copy on select is a setting (Settings › Shell › Selection). The scrollback keeps 10,000 lines; ⌘K clears it. ⌘F searches the scrollback. Every match on screen is tinted and the current one is outlined; the bar counts them ("3 of 14"), and marks down the pane's right edge show where the rest are. Return or ⌘G goes to the next match, ⇧Return or ⇧⌘G to the previous one (↓ and ↑ do too), wrapping around; Esc closes the bar. **Aa** matches case, **ab** whole words, **.\*** a regular expression. With text selected, ⌘F (or ⌘E) searches for it. Matches keep up as the pane prints more. diff --git a/docs/files-and-editor.md b/docs/files-and-editor.md index 3aaca6a..a39e889 100644 --- a/docs/files-and-editor.md +++ b/docs/files-and-editor.md @@ -24,7 +24,7 @@ The left sidebar has two tabs: Files and [Intel](intel.md). ## Editor -Click a file in the tree to see it beside the terminal. The next click replaces it, as a preview tab (in italics); double-click, press Return, or start typing to keep it in a tab of its own. +Click a file in the tree to see it beside the terminal, or ⌘-click its name in a pane, wherever `ls`, `git status` or a compiler printed it (see [the command line](command-line.md#opening-files-and-links)). The next click replaces it, as a preview tab (in italics); double-click, press Return, or start typing to keep it in a tab of its own. Code is colored by Codec's own highlighter, from the theme's palette: Swift, JavaScript and TypeScript, Python, Ruby, Go, Rust, C, C++ and Objective-C, Java, Kotlin, C#, PHP, Lua, SQL, shell scripts and Dockerfiles, JSON, YAML, TOML and INI, HTML and XML, CSS, and Markdown. diff --git a/docs/images/open-file.png b/docs/images/open-file.png new file mode 100644 index 0000000..668eeb7 Binary files /dev/null and b/docs/images/open-file.png differ diff --git a/docs/shortcuts.md b/docs/shortcuts.md index bb9f6c4..9b299be 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -28,6 +28,7 @@ | ⌘E | Find the selected text | | ⌘+ / ⌘− / ⌘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 | ## Command line (zsh)