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
121 changes: 118 additions & 3 deletions mac/Vendor/alacritty-bridge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,14 +466,14 @@ const SYNCHRONIZED_UPDATE_TIMEOUT: Duration = Duration::from_millis(150);
/// punctuation is excluded so a URL followed by Chinese or Japanese prose
/// without spaces ends where the sentence resumes.
#[rustfmt::skip]
const LINK_REGEX: &str = "((ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)|\
const LINK_REGEX: &str = "(?i)((ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)|\
(/|~/|\\./|\\.\\./|[A-Za-z0-9._@%+~-]+/))\
[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\。,、;:?!)]+\
|(?:www\\.)?[A-Za-z0-9][A-Za-z0-9-]*(?:\\.[A-Za-z0-9-]+)*\\.\
(?:com|net|org|edu|gov|mil|int|info|xyz|top|site|online|cloud|vip|app|dev|io|ai|me|cc|tv|fm|gg|sh|so|co|\
cn|jp|de|uk|ru|fr|kr|us|nl|se|no|fi|dk|es|it|pt|pl|cz|at|ch|be|au|br|mx|in|hk|tw|sg|my|id|ca|ie|nz|za)\
(?::\\d{1,5})?(?:/[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\。,、;:?!)]*)?\
|localhost(?::\\d{1,5})?(?:/[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\。,、;:?!)]*)?";
(?::\\d{1,5})?(?:[/?#][^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\。,、;:?!)]*)?\
|localhost(?::\\d{1,5})?(?:[/?#][^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\。,、;:?!)]*)?";

/// Avoid walking an effectively unbounded soft-wrapped logical line on hover.
const MAX_URL_SEARCH_LINES: i32 = 100;
Expand Down Expand Up @@ -1917,6 +1917,43 @@ fn post_process_url_match<T: EventListener>(term: &Term<T>, regex_match: &Match)
(start <= iter.point()).then(|| start..=iter.point())
}

// A known TLD must end the hostname, not merely prefix a longer word or
// dotted filename. Keep sentence punctuation outside the returned link.
fn plain_url_has_token_boundaries<T: EventListener>(term: &Term<T>, bounds: &Match) -> bool {
let continues_word = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_');
let mut before = term.grid().iter_from(*bounds.start());
if let Some(previous) = before.prev() {
if (previous.point.line == bounds.start().line
|| previous.cell.flags.contains(Flags::WRAPLINE))
&& (continues_word(previous.cell.c) || previous.cell.c == '.')
{
return false;
}
}
let mut after = term.grid().iter_from(*bounds.end());
if let Some(next) = after.next() {
if next.point.line != bounds.end().line
&& !term.grid()[*bounds.end()].flags.contains(Flags::WRAPLINE)
{
return true;
}
if continues_word(next.cell.c) {
return false;
}
if next.cell.c == '.' {
if let Some(after_dot) = after.next() {
if (after_dot.point.line == next.point.line
|| next.cell.flags.contains(Flags::WRAPLINE))
&& continues_word(after_dot.cell.c)
{
return false;
}
}
}
}
true
}

/// Finds Alacritty's default plain-text URL hint under a grid point.
fn plain_url_at<T: EventListener>(
term: &Term<T>,
Expand All @@ -1941,6 +1978,9 @@ fn plain_url_at<T: EventListener>(
if processed.as_ref().is_some_and(|rm| rm.contains(&point)) {
let bounds = processed.unwrap();
let url = term.bounds_to_string(*bounds.start(), *bounds.end());
if !plain_url_has_token_boundaries(term, &bounds) {
return None;
}
return Some((url, bounds));
}

Expand Down Expand Up @@ -2221,6 +2261,81 @@ mod tests {
Point::new(Line((offset / 40) as i32), Column(offset % 40))
}

#[test]
fn plain_url_lookup_preserves_query_fragment_and_case() {
for content in [
"HTTPS://EXAMPLE.COM",
"EXAMPLE.COM",
"example.com?q=hello",
"example.com#section",
"LOCALHOST:3000?q=test#result",
] {
let term = parse(content.as_bytes());
assert_eq!(
url_in(&term, ascii_point(content, content)),
Some(content.to_owned()),
"{content}"
);
}
}

#[test]
fn plain_url_lookup_rejects_partial_hosts_and_filenames() {
for content in [
"foo.completion",
"foo.com.ts",
"localhosted",
"my-localhost",
"www.example.completion",
] {
let term = parse(content.as_bytes());
for column in 0..content.len() {
assert_eq!(
url_in(&term, Point::new(Line(0), Column(column))),
None,
"{content}:{column}"
);
}
}
}

#[test]
fn plain_url_lookup_checks_boundaries_across_soft_wraps() {
for content in [
format!("{}foo.completion", " ".repeat(33)),
format!("{}foo.com.ts", " ".repeat(33)),
format!("{}localhost", "a".repeat(40)),
] {
let term = parse(content.as_bytes());
let point = ascii_point(
&content,
if content.contains("foo") {
"foo"
} else {
"localhost"
},
);
assert_eq!(url_in(&term, point), None, "{content}");
}
}

#[test]
fn plain_url_lookup_keeps_sentence_punctuation_outside_hosts() {
for content in [
"example.com.",
"example.com...",
"(example.com).",
"example.com, next",
] {
let term = parse(content.as_bytes());
assert_eq!(
url_in(&term, ascii_point(content, "example")),
Some("example.com".to_owned()),
"{content}"
);
}
}

#[test]
fn plain_url_lookup_uses_alacritty_hint_delimiters() {
let content = "visit (https://example.com/docs). next";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,69 @@
surface?.isMouseCaptured ?? false
}

/// Refreshes link callbacks at a context click and reads its text token
/// without changing the terminal selection or the system pointer.
public func contextText(for event: NSEvent) -> String? {
guard let surface, let rawSurface = surface.rawValue else { return nil }
let point = mousePoint(from: event)
let mods = TerminalInputModifiers(from: event.modifierFlags).ghosttyMods
// Ghostty skips equal coordinates even when modifiers or output changed.
surface.sendMousePos(x: -1, y: -1, mods: mods)
surface.sendMousePos(x: point.x, y: point.y, mods: mods)
guard let word = surface.quicklookWord(), !word.word.isEmpty else { return nil }

func read(_ end: ghostty_point_s) -> (text: String, start: UInt32, length: UInt32)? {
let selection = ghostty_selection_s(
top_left: ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0
),
bottom_right: end,
rectangle: false
)
var result = ghostty_text_s()
guard ghostty_surface_read_text(rawSurface, selection, &result) else { return nil }
defer { ghostty_surface_free_text(rawSurface, &result) }
let text = result.text.map {
String(decoding: UnsafeRawBufferPointer(start: $0, count: Int(result.text_len)), as: UTF8.self)
} ?? ""
return (text, result.offset_start, result.offset_len)
}

// Native offsets count cells, not bytes. A prefix read maps the
// word's first cell to a String index, including wide Unicode cells.
guard let size = surface.size(),
let firstRow = read(ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_EXACT,
x: UInt32(size.columns), y: 0
)),
firstRow.length < UInt32.max,
word.offsetStart >= firstRow.start
else { return word.word }
let columns = firstRow.length + 1
let offset = word.offsetStart - firstRow.start
guard let prefix = read(ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_EXACT,
x: offset % columns, y: offset / columns
)), let viewport = read(ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0
)) else { return word.word }
let anchor = prefix.text.utf16.count - String(word.word.prefix(1)).utf16.count
guard anchor >= 0, anchor < viewport.text.utf16.count else { return word.word }
let text = viewport.text
var lower = String.Index(utf16Offset: anchor, in: text)
var upper = lower
func isBoundary(_ character: Character) -> Bool {
character.isWhitespace || "<>\"'`。,、;:?!)".contains(character)
}
while lower > text.startIndex, !isBoundary(text[text.index(before: lower)]) {
lower = text.index(before: lower)
}
while upper < text.endIndex, !isBoundary(text[upper]) {
upper = text.index(after: upper)
}
return String(text[lower..<upper]).trimmingCharacters(in: CharacterSet(charactersIn: "()[]{}"))
}

/// Search the screen and scrollback for `needle`, replacing any active
/// search. An empty needle cancels without dismissing host search UI.
@discardableResult
Expand Down
108 changes: 108 additions & 0 deletions mac/tests/test_file_content_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Exercise the search model against macOS grep without opening app windows.

Run: python3 mac/tests/test_file_content_search.py
"""
import json
from pathlib import Path
import subprocess
import tempfile
import unittest

MAC = Path(__file__).resolve().parents[1]


class FileContentSearchTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.build = tempfile.TemporaryDirectory(prefix="zshell-search-test-")
cls.addClassCleanup(cls.build.cleanup)
helper = Path(cls.build.name) / "SearchHarness.swift"
helper.write_text(r'''
import Foundation

@main struct SearchHarness {
@MainActor static func main() async throws {
let model = FileContentSearchModel()
model.sync(root: CommandLine.arguments[1])
model.query = CommandLine.arguments[2]
model.run()
if CommandLine.arguments.count > 3 {
model.query = CommandLine.arguments[3]
model.run()
}
for _ in 0..<500 {
if !model.isRunning { break }
try await Task.sleep(for: .milliseconds(10))
}
let result: [String: Any] = [
"running": model.isRunning,
"truncated": model.isTruncated,
"failed": model.failureMessage != nil,
"matches": model.matches.map {
["path": $0.path, "line": $0.line, "content": $0.content] as [String: Any]
},
]
print(String(decoding: try JSONSerialization.data(withJSONObject: result), as: UTF8.self))
}
}
''')
cls.helper = Path(cls.build.name) / "search"
subprocess.run([
"swiftc", "-parse-as-library",
str(MAC / "zshell/FileContentSearchModel.swift"),
str(MAC / "zshell/MainActorIsolation.swift"),
str(helper), "-o", str(cls.helper),
], check=True)

def setUp(self):
self.fixture = tempfile.TemporaryDirectory(prefix="zshell-search-fixture-")
self.addCleanup(self.fixture.cleanup)
self.root = Path(self.fixture.name)

def search(self, query="needle", replacement=None):
args = [str(self.helper), str(self.root), query]
if replacement is not None:
args.append(replacement)
result = json.loads(subprocess.check_output(args, text=True, timeout=8))
self.assertFalse(result["running"], "grep did not complete")
self.assertFalse(result["failed"], "grep failed")
return result

def test_file_names_keep_colons_and_newlines(self):
names = ["plain.txt", "has:colon.txt", "has\nnewline.txt"]
for name in names:
(self.root / name).write_text("first\nneedle\n")
result = self.search()
self.assertEqual({hit["path"] for hit in result["matches"]}, set(names))
self.assertTrue(all(hit["line"] == 2 for hit in result["matches"]))

def test_fast_exit_keeps_every_pipe_chunk(self):
(self.root / "many.txt").write_text("needle\n" * 1999)
result = self.search()
self.assertEqual(len(result["matches"]), 1999)
self.assertEqual(result["matches"][-1]["line"], 1999)
self.assertFalse(result["truncated"])

def test_match_limit_finishes_with_exact_cap(self):
(self.root / "many.txt").write_text("needle\n" * 5000)
result = self.search()
self.assertEqual(len(result["matches"]), 2000)
self.assertTrue(result["truncated"])

def test_replaced_search_cannot_publish_old_matches_or_limit(self):
(self.root / "many.txt").write_text("old\n" * 5000 + "new\n")
result = self.search("old", "new")
self.assertEqual(result["matches"], [
{"path": "many.txt", "line": 5001, "content": "new"},
])
self.assertFalse(result["truncated"])

def test_query_starting_with_dash_and_binary_exclusion(self):
(self.root / "plain.txt").write_text("-needle\n")
(self.root / "binary.bin").write_bytes(b"\0-needle\n")
result = self.search("-needle")
self.assertEqual([hit["path"] for hit in result["matches"]], ["plain.txt"])


if __name__ == "__main__":
unittest.main(verbosity=2)
Loading
Loading