diff --git a/mac/Vendor/alacritty-bridge/src/lib.rs b/mac/Vendor/alacritty-bridge/src/lib.rs index ae4c346..a219c88 100644 --- a/mac/Vendor/alacritty-bridge/src/lib.rs +++ b/mac/Vendor/alacritty-bridge/src/lib.rs @@ -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; @@ -1917,6 +1917,43 @@ fn post_process_url_match(term: &Term, 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(term: &Term, 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( term: &Term, @@ -1941,6 +1978,9 @@ fn plain_url_at( 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)); } @@ -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"; diff --git a/mac/Vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+PublicInput.swift b/mac/Vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+PublicInput.swift index 2568c90..ef638bc 100644 --- a/mac/Vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+PublicInput.swift +++ b/mac/Vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+PublicInput.swift @@ -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.. 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) diff --git a/mac/tests/test_ghostty_link_position.py b/mac/tests/test_ghostty_link_position.py new file mode 100644 index 0000000..ac74523 --- /dev/null +++ b/mac/tests/test_ghostty_link_position.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Exercise click-position links with the real Ghostty engine in a hidden window.""" + +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +REPO = Path(__file__).resolve().parents[2] + +HARNESS = r''' +import AppKit +import GhosttyKit +@testable import GhosttyTerminal + +enum Failure: Error { case check(String) } + +@main struct Harness { + @MainActor static func main() { + do { + try run() + } catch { + print("FAIL", error) + exit(1) + } + } + + @MainActor static func run() throws { + setbuf(stdout, nil) + _ = NSApplication.shared + let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + try FileManager.default.createDirectory( + at: directory.appendingPathComponent("Sources"), withIntermediateDirectories: true + ) + let file = directory.appendingPathComponent("Sources/main.swift") + try Data("fixture".utf8).write(to: file) + let session = InMemoryTerminalSession(write: { _ in }, resize: { _ in }) + let controller = TerminalController(configSource: .generated( + "font-family = Menlo\nfont-size = 14\nwindow-padding-x = 0\n" + + "window-padding-y = 0\nwindow-padding-balance = false\nshell-integration = none\n" + )) + let view = ProbeView(frame: NSRect(x: 0, y: 0, width: 800, height: 300)) + // A hidden-window test must not change the user's system cursor. + view.core.onMouseShapeChange = { _ in } + view.delegate = view + view.events = Session(currentDirectoryPath: directory.path) + view.configuration = TerminalSurfaceOptions(backend: .inMemory(session)) + view.controller = controller + let window = NSWindow(contentRect: view.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + defer { + view.controller = nil + window.contentView = nil + window.close() + } + let longURL = "www.example.com/" + String(repeating: "segment/", count: 14) + "end" + session.receive( + "https://a.example\r\nhttps://b.example\r\nwww.example.com:8080/docs?q=x#h\r\n" + + "前 😀 www.example.com/x\r\n./Sources/main.swift:42\r\n" + + "\u{1b}]8;;https://osc.example/exact\u{1b}\\Click me\u{1b}]8;;\u{1b}\\\r\n" + + "localhost:3000/health\r\nplain text\r\n" + longURL + ) + let deadline = Date().addingTimeInterval(3) + while session.readViewportText()?.contains("segment/end") != true && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + guard let metrics = view.metrics else { throw Failure.check("missing grid metrics") } + let width = CGFloat(metrics.cellWidthPixels) / window.backingScaleFactor + let height = CGFloat(metrics.cellHeightPixels) / window.backingScaleFactor + func event(_ row: Int, column: Int = 3, modifiers: NSEvent.ModifierFlags = .command) -> NSEvent { + NSEvent.mouseEvent( + with: .rightMouseDown, + location: NSPoint( + x: (CGFloat(column) + 0.5) * width, + y: view.bounds.height - (CGFloat(row) + 0.5) * height + ), + modifierFlags: modifiers, timestamp: 0, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: 1, pressure: 1 + )! + } + var checks = 0 + func check(_ name: String, _ event: NSEvent, _ expected: TerminalLinkTarget?) throws { + let result = view.target(event) + guard result == expected else { + throw Failure.check("\(name): \(String(describing: result)) != \(String(describing: expected))") + } + checks += 1 + print("PASS", name) + } + view.mouseMoved(with: event(0, modifiers: [])) + try check("same position adds Command", event(0), .url(URL(string: "https://a.example")!)) + try check("first cell refresh", event(0, column: 0), .url(URL(string: "https://a.example")!)) + try check("new row without hover", event(1), .url(URL(string: "https://b.example")!)) + try check( + "bare host port query and fragment", event(2), + .url(URL(string: "https://www.example.com:8080/docs?q=x#h")!) + ) + try check("Unicode prefix", event(3, column: 10), .url(URL(string: "https://www.example.com/x")!)) + try check("relative diagnostic file", event(4), .file(file.standardizedFileURL)) + try check("OSC8 target keeps destination", event(5), .url(URL(string: "https://osc.example/exact")!)) + try check("localhost retains port", event(6), .url(URL(string: "http://localhost:3000/health")!)) + try check("blank text drops old hover", event(7), nil) + try check("soft wrapped bare URL", event(8), .url(URL(string: "https://" + longURL)!)) + try check("unmodified click is not a link action", event(0, modifiers: []), nil) + _ = view.target(event(0)) + session.receive("\u{1b}[1;1Hhttps://c.example\u{1b}[K") + let changedDeadline = Date().addingTimeInterval(3) + while session.readViewportText()?.contains("https://c.example") != true && Date() < changedDeadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + try check("changed output under stationary pointer", event(0), .url(URL(string: "https://c.example")!)) + session.receive("\u{1b}[?1003h\u{1b}[?1006h") + let captureDeadline = Date().addingTimeInterval(3) + while !view.isMouseCaptured && Date() < captureDeadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + guard view.isMouseCaptured else { throw Failure.check("mouse capture was not enabled") } + try check( + "mouse capture keeps host Command link action", event(2), + .url(URL(string: "https://www.example.com:8080/docs?q=x#h")!) + ) + session.receive("\u{1b}[?1003l\u{1b}[?1006l\u{1b}[2J\u{1b}[H👩🏽‍💻 e\u{301} https://example.com/\r\n") + let unicodeDeadline = Date().addingTimeInterval(3) + while session.readViewportText()?.contains("👩🏽‍💻") != true && Date() < unicodeDeadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + let unicodeEvent = event(0, column: 15) + guard view.contextText(for: unicodeEvent) == "https://example.com/" else { + throw Failure.check("emoji and combining prefix truncated the context token") + } + try check("emoji and combining prefix", unicodeEvent, .url(URL(string: "https://example.com/")!)) + + let scrolledLink = "www.example.com/scrollback?row=7#anchor" + session.receive("\u{1b}[2J\u{1b}[H" + (0..<50).map { + ($0 == 7 ? scrolledLink : "scrollback row \($0)") + "\r\n" + }.joined()) + let outputDeadline = Date().addingTimeInterval(3) + while session.readViewportText()?.contains("scrollback row 49") != true && Date() < outputDeadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + guard let bottomViewport = session.readViewportText(), + bottomViewport.contains("scrollback row 49"), !bottomViewport.contains(scrolledLink) + else { throw Failure.check("scrollback fixture has not reached the bottom") } + guard view.scrollToRow(5) else { throw Failure.check("scrollToRow failed") } + let scrollDeadline = Date().addingTimeInterval(3) + while session.readViewportText()?.contains(scrolledLink) != true && Date() < scrollDeadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + guard let row = session.readViewportText()?.components(separatedBy: "\n").firstIndex(of: scrolledLink), row > 0, + let rawSurface = view.surface?.rawValue else { throw Failure.check("scrollback link is not visible") } + let firstRow = ghostty_selection_s( + top_left: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0), + bottom_right: ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_EXACT, + x: UInt32(metrics.columns), y: 0 + ), + rectangle: false + ) + var firstRowText = ghostty_text_s() + guard ghostty_surface_read_text(rawSurface, firstRow, &firstRowText) else { + throw Failure.check("cannot read the first scrollback row") + } + let firstRowOffset = firstRowText.offset_start + ghostty_surface_free_text(rawSurface, &firstRowText) + guard view.contextText(for: event(row)) == scrolledLink else { + throw Failure.check("scrollback offset truncated the context token") + } + guard let word = view.surface?.quicklookWord(), word.offsetStart > firstRowOffset else { + throw Failure.check("scrollback must exercise a later visible row") + } + try check("scrolled viewport resolves visible link", event(row), .url(URL(string: "https://" + scrolledLink)!)) + print("Ghostty context links: \(checks) assertions passed") + } +} +''' + + +class GhosttyLinkPositionTests(unittest.TestCase): + def test_current_click_and_link_text(self): + session_source = (REPO / "mac/zshell/TerminalSession.swift").read_text() + classifier = session_source[ + session_source.index(" func terminalLinkTarget(for value:"): + session_source.index(" func terminalDidScroll(") + ] + view_source = (REPO / "mac/zshell/ZshellTerminalView.swift").read_text() + link_target = view_source[ + view_source.index(" private func linkTarget(for event:"): + view_source.index(" private func contextMenu(") + ] + with tempfile.TemporaryDirectory(prefix="zshell-ghostty-links-") as directory: + package = Path(directory) + sources = package / "Sources/Harness" + sources.mkdir(parents=True) + (sources / "Links.swift").write_text( + "import AppKit\n@testable import GhosttyTerminal\n" + "enum TerminalLinkTarget: Equatable { case file(URL), url(URL) }\n" + "struct Session {\n" + " var foregroundDirectoryPath: String? = nil\n" + " let currentDirectoryPath: String\n" + + classifier + "\n}\n" + "@MainActor final class ProbeView: AppTerminalView, " + "TerminalSurfaceHoverLinkDelegate, TerminalSurfaceGridResizeDelegate {\n" + " var hoveredLink: String?\n" + " var metrics: TerminalGridMetrics?\n" + " var events: Session?\n" + " func terminalDidUpdateHoverLink(_ url: String?) { hoveredLink = url }\n" + " func terminalDidResize(_ size: TerminalGridMetrics) { metrics = size }\n" + " func target(_ event: NSEvent) -> TerminalLinkTarget? { linkTarget(for: event) }\n" + + link_target + "\n}\n" + ) + (sources / "Harness.swift").write_text(HARNESS) + ghostty_path = str(REPO / "mac/Vendor/libghostty-spm").replace("\\", "\\\\").replace('"', '\\"') + (package / "Package.swift").write_text( + "// swift-tools-version: 6.0\n" + "import PackageDescription\n" + 'let package = Package(name: "GhosttyLinkRegression", ' + 'platforms: [.macOS(.v14)], ' + f'dependencies: [.package(path: "{ghostty_path}")], ' + 'targets: [.executableTarget(name: "Harness", dependencies: ' + '[.product(name: "GhosttyTerminal", package: "libghostty-spm")])], ' + 'swiftLanguageModes: [.v5])\n' + ) + result = subprocess.run( + [ + "swift", "run", "--package-path", str(package), + "--scratch-path", str(package / "build"), "Harness", + str(package / "fixture"), + ], + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env={**os.environ, "TMPDIR": str(package)}, + timeout=180, + ) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("15 assertions passed", result.stdout) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/mac/tests/test_project_file_search.py b/mac/tests/test_project_file_search.py new file mode 100644 index 0000000..999f966 --- /dev/null +++ b/mac/tests/test_project_file_search.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Exercise project indexing against real Git, filesystem aliases, and FuzzyMatch.""" + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +REPO = Path(__file__).resolve().parents[2] + +HARNESS = r''' +import Foundation +@main struct Harness { + static func main() async throws { + let fm=FileManager.default + let root=URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + defer { try? fm.removeItem(at:root) } + let gitRoot=root.appendingPathComponent("gitA"), plainRoot=root.appendingPathComponent("plainB"), homeRoot=root.appendingPathComponent("home") + for path in [gitRoot,plainRoot,homeRoot,gitRoot.appendingPathComponent("src"),gitRoot.appendingPathComponent("sub"),plainRoot.appendingPathComponent(".git/objects")] { try fm.createDirectory(at:path,withIntermediateDirectories:true) } + func write(_ path:URL,_ value:String="fixture") throws { try Data(value.utf8).write(to:path) } + try write(gitRoot.appendingPathComponent(".gitignore"),"ignored.log\n") + try write(gitRoot.appendingPathComponent("src/main.swift")) + try write(gitRoot.appendingPathComponent("space newline\nname.swift")) + try write(gitRoot.appendingPathComponent("sub/common.swift")) + try write(gitRoot.appendingPathComponent("README.md")) + try write(gitRoot.appendingPathComponent("ignored.log")) + try write(plainRoot.appendingPathComponent("main.swift")) + try write(plainRoot.appendingPathComponent("guide.txt")) + try write(plainRoot.appendingPathComponent(".git/objects/dummy")) + let runner=GitCommandRunner() + let initialized=await runner.run(["init","-q"],in:gitRoot.path); precondition(initialized.status==0) + let staged=await runner.run(["add","--",".gitignore","src/main.swift","space newline\nname.swift","sub/common.swift"],in:gitRoot.path); precondition(staged.status==0) + let alias=root.appendingPathComponent("gitAlias"); try fm.createSymbolicLink(at:alias,withDestinationURL:gitRoot) + var checks=0 + func check(_ value:Bool) { precondition(value, "failed check \(checks + 1)"); checks += 1 } + func searchRoot(_ url:URL, name:String) -> ProjectFileSearchRoot { ProjectFileSearchRoot(projectID:UUID(),projectName:name,root:url.path,homeDirectory:homeRoot.path)! } + check(ProjectFileSearchRoot(projectID:UUID(),projectName:"Home",root:homeRoot.path,homeDirectory:homeRoot.path)==nil) + check(ProjectFileSearchRoot(projectID:UUID(),projectName:"Missing",root:root.appendingPathComponent("missing").path,homeDirectory:homeRoot.path)==nil) + let roots=[searchRoot(gitRoot,name:"Git"),searchRoot(alias,name:"Alias"),searchRoot(gitRoot.appendingPathComponent("sub"),name:"Nested"),searchRoot(plainRoot,name:"Plain")] + check(ProjectFileSearch.canonicalRoots(roots).count==3) + let files=await ProjectFileSearch.index(roots:roots) + check(files.count==7) + check(Set(files.map(\.canonicalAbsolutePath)).count==files.count) + check(!files.contains { $0.name=="ignored.log" || $0.relativePath.hasPrefix(".git/") }) + check(files.contains { $0.relativePath=="space newline\nname.swift" }) + let matches=await ProjectFileSearch.search("main",in:files) + check(matches.count==2) + check(Set(matches.map(\.file.projectRoot)).count==2) + check(matches.allSatisfy { $0.file.name=="main.swift" }) + let limited=await ProjectFileSearch.search("swift",in:files,limit:1) + check(limited.count==1) + let empty=await ProjectFileSearch.search(" ",in:files) + check(empty.isEmpty) + let cancel=Task { await ProjectFileSearch.index(roots:roots) }; cancel.cancel() + let cancelled=await cancel.value + check(cancelled.isEmpty) + print("Project file search: real Git and filesystem, \(checks) assertions passed") + } +} +''' + + +class ProjectFileSearchTests(unittest.TestCase): + def test_real_project_roots_and_search(self): + with tempfile.TemporaryDirectory(prefix="zshell-project-search-") as directory: + package = Path(directory) + sources = package / "Sources" / "Harness" + sources.mkdir(parents=True) + for name in ("ProjectFileSearch.swift", "GitCommandRunner.swift"): + shutil.copy2(REPO / "mac" / "zshell" / name, sources / name) + (sources / "Harness.swift").write_text(HARNESS) + fuzzy_path = str(REPO / "mac" / "Vendor" / "FuzzyMatch") + (package / "Package.swift").write_text( + "// swift-tools-version: 6.0\n" + "import PackageDescription\n" + 'let package = Package(name: "ProjectSearchRegression", ' + 'platforms: [.macOS(.v14)], ' + f'dependencies: [.package(path: "{fuzzy_path}")], ' + 'targets: [.executableTarget(name: "Harness", dependencies: ' + '[.product(name: "FuzzyMatch", package: "FuzzyMatch")])])\n' + ) + result = subprocess.run( + [ + "swift", "run", "--package-path", str(package), + "--scratch-path", str(package / "build"), "Harness", + str(package / "fixture"), + ], + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=120, + ) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("13 assertions passed", result.stdout) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/mac/tests/test_prompt_selection.py b/mac/tests/test_prompt_selection.py index a1e6f05..1c94d8d 100644 --- a/mac/tests/test_prompt_selection.py +++ b/mac/tests/test_prompt_selection.py @@ -45,6 +45,8 @@ def setUpClass(cls): 'let directory = URL(fileURLWithPath: CommandLine.arguments[2])\n' 'if CommandLine.arguments[1] == "generate" {\n' 'print(try Session.makeShellIntegrationArtifacts(in: directory, shellPath: "/bin/zsh")!.path)\n' + '} else if CommandLine.arguments[1] == "queue" {\n' + 'print(Session(launchDirectoryURL: directory, surface: Surface(foregroundPid: pid_t(CommandLine.arguments[3]))).terminalPromptQueueIsReady)\n' '} else {\n' 'print(Session(launchDirectoryURL: directory, surface: Surface(foregroundPid: pid_t(CommandLine.arguments[3]))).terminalPromptSelectionIsReady)\n}\n') cls.helper = Path(cls.build.name) / "integration" @@ -110,6 +112,11 @@ def ready(self, pid=None): [str(self.helper), "ready", str(self.root), str(pid or self.pid)], text=True).strip() == "true" + def queue_ready(self): + return subprocess.check_output( + [str(self.helper), "queue", str(self.root), str(self.pid)], + text=True).strip() == "true" + def buffer(self, keys): self.buffer_file.unlink(missing_ok=True) os.write(self.fd, keys + b"\x14") @@ -123,6 +130,22 @@ def buffer(self, keys): def test_repeated_click_preserves_text(self): self.assertEqual(self.buffer("abc中文".encode() + CLICK * 10), "abc中文") + def test_prompt_queue_does_not_append_to_a_draft(self): + self.assertTrue(self.queue_ready()) + self.assertEqual(self.buffer(b"echo unfinished-draft"), "echo unfinished-draft") + self.assertTrue(self.ready()) + self.assertFalse(self.queue_ready()) + self.assertEqual(self.buffer(b"\x15"), "") + self.assertTrue(self.queue_ready()) + + def test_prompt_queue_rejects_a_foreground_command(self): + os.write(self.fd, b"read -r answer\r") + self.drain() + self.assertFalse(self.queue_ready()) + os.write(self.fd, b"answer\r") + self.wait_prompt() + self.assertTrue(self.queue_ready()) + def test_selection_replace_and_delete(self): # Place the mark at end, select the last two characters, then replace. self.assertEqual(self.buffer(b"abcd\x1f\x1b[D\x1b[D\x1eX"), "abX") diff --git a/mac/tests/test_terminal_links.py b/mac/tests/test_terminal_links.py new file mode 100644 index 0000000..d4b4e43 --- /dev/null +++ b/mac/tests/test_terminal_links.py @@ -0,0 +1,86 @@ +"""Exercise terminal link classification against real temporary file paths. + +Run: python3 mac/tests/test_terminal_links.py +""" +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +SOURCE = Path(__file__).resolve().parents[1] / "zshell/TerminalSession.swift" + + +class TerminalLinkTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.build = tempfile.TemporaryDirectory(prefix="zshell-link-test-") + cls.addClassCleanup(cls.build.cleanup) + source = SOURCE.read_text() + classifier = source[ + source.index(" func terminalLinkTarget(for value:"): + source.index(" func terminalDidScroll(") + ] + helper = Path(cls.build.name) / "main.swift" + helper.write_text( + "import Foundation\n" + "enum TerminalLinkTarget { case file(URL), url(URL) }\n" + "struct Session {\n" + "var foregroundDirectoryPath: String? = nil\n" + "let currentDirectoryPath: String\n" + + classifier + "\n}\n" + 'let session = Session(currentDirectoryPath: CommandLine.arguments[1])\n' + 'let result: [String: String]\n' + 'switch session.terminalLinkTarget(for: CommandLine.arguments[2]) {\n' + 'case .file(let url): result = ["kind": "file", "value": url.path]\n' + 'case .url(let url): result = ["kind": "url", "value": url.absoluteString]\n' + 'case nil: result = ["kind": "none"]\n}\n' + 'print(String(decoding: try JSONSerialization.data(withJSONObject: result), as: UTF8.self))\n' + ) + cls.helper = Path(cls.build.name) / "links" + subprocess.run(["swiftc", str(helper), "-o", str(cls.helper)], check=True) + + def setUp(self): + self.fixture = tempfile.TemporaryDirectory(prefix="zshell-link-fixture-") + self.addCleanup(self.fixture.cleanup) + self.root = Path(self.fixture.name) + + def classify(self, value): + return json.loads(subprocess.check_output( + [str(self.helper), str(self.root), value], text=True, + )) + + def test_bare_hosts_with_ports_open_with_web_schemes(self): + for value, expected in [ + ("localhost:3000", "http://localhost:3000"), + ("example.com:8080/path", "https://example.com:8080/path"), + ("localhost.example.com:3000", "https://localhost.example.com:3000"), + ("www.example.com。", "https://www.example.com"), + ]: + with self.subTest(value=value): + self.assertEqual(self.classify(value), {"kind": "url", "value": expected}) + + def test_relative_diagnostic_location_resolves_to_file(self): + path = self.root / "main.swift" + path.write_text("let value = 1\n") + self.assertEqual(self.classify("main.swift:12:3"), { + "kind": "file", "value": str(path), + }) + + def test_literal_colon_path_takes_precedence(self): + path = self.root / "name:12" + path.write_text("fixture\n") + self.assertEqual(self.classify("name:12"), {"kind": "file", "value": str(path)}) + + def test_existing_schemes_and_plain_non_links(self): + self.assertEqual(self.classify("https://example.com/a"), { + "kind": "url", "value": "https://example.com/a", + }) + self.assertEqual(self.classify("mailto:test@example.com"), { + "kind": "url", "value": "mailto:test@example.com", + }) + self.assertEqual(self.classify("config.yaml"), {"kind": "none"}) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/mac/zshell/AgentPalette.swift b/mac/zshell/AgentPalette.swift index 095ac5f..737d7df 100644 --- a/mac/zshell/AgentPalette.swift +++ b/mac/zshell/AgentPalette.swift @@ -444,7 +444,7 @@ private final class AgentPalettePanelView: NSView { layer?.cornerCurve = .continuous layer?.masksToBounds = true layer?.borderWidth = 1 - layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.35).cgColor + updateAppearanceColors() background.material = .hudWindow background.blendingMode = .withinWindow background.state = .active @@ -466,6 +466,22 @@ private final class AgentPalettePanelView: NSView { fatalError("init(coder:) has not been implemented") } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateAppearanceColors() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateAppearanceColors() + } + + private func updateAppearanceColors() { + effectiveAppearance.performAsCurrentDrawingAppearance { + layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.35).cgColor + } + } + func install( searchField: NSSearchField, scrollView: NSScrollView, diff --git a/mac/zshell/AppKitProjectSidebarView.swift b/mac/zshell/AppKitProjectSidebarView.swift new file mode 100644 index 0000000..bb8c6f4 --- /dev/null +++ b/mac/zshell/AppKitProjectSidebarView.swift @@ -0,0 +1,515 @@ +// +// AppKitProjectSidebarView.swift +// zshell +// + +import AppKit +import Combine +import SwiftUI + +struct ProjectSidebarRepresentable: NSViewRepresentable { + let manager: TerminalManager + let tabDrag: TabSplitDragCoordinator + let bottomBarHeight: CGFloat + + func makeNSView(context: Context) -> ProjectSidebarNSView { + ProjectSidebarNSView(manager: manager, tabDrag: tabDrag, bottomBarHeight: bottomBarHeight) + } + + func updateNSView(_ view: ProjectSidebarNSView, context: Context) { + view.bottomBarHeight = bottomBarHeight + view.refresh() + } + + static func dismantleNSView(_ view: ProjectSidebarNSView, coordinator: ()) { view.detach() } +} + +private final class ProjectSidebarDocumentView: NSView { + override var isFlipped: Bool { true } +} + +private final class ProjectSidebarOutlineView: NSView { + var footerHeight: CGFloat = 0 + var drawsOuterEdge = false + var dropFrame: NSRect? + override var isFlipped: Bool { true } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func draw(_ dirtyRect: NSRect) { + Theme.divider.setFill() + NSRect(x: 0, y: bounds.height - footerHeight, width: bounds.width, height: 1).fill() + if drawsOuterEdge { NSRect(x: bounds.width - 1, y: 0, width: 1, height: bounds.height).fill() } + if let dropFrame { + let path = NSBezierPath(roundedRect: dropFrame.insetBy(dx: 4, dy: 4), xRadius: 6, yRadius: 6) + Theme.accent.setStroke() + path.lineWidth = 2 + path.setLineDash([5, 4], count: 2, phase: 0) + path.stroke() + } + } +} + +final class ProjectSidebarNSView: NSView { + private enum Item: Hashable { case ungrouped, project(UUID), group(UUID) } + private let manager: TerminalManager + private let tabDrag: TabSplitDragCoordinator + private let groupStore = ProjectGroupStore.shared + private let material = NSVisualEffectView() + private let outline = ProjectSidebarOutlineView() + private let windowDrag = WorkspaceWindowDragView() + private let scrollView = NSScrollView() + private let document = ProjectSidebarDocumentView() + private let sidebarButton = WorkspaceChromeButton(symbol: "sidebar.left", label: AppCommand.toggleLeftSidebar.title) + private let fpsLabel = NSTextField(labelWithString: "") + private let fpsCounter = FPSCounter() + private let menuPresenter = AppKitContextMenuMonitorView() + private var footerButtons: [WorkspaceChromeButton] = [] + private var rows: [Item: WorkspaceItemView] = [:] + private var order: [Item] = [] + private var observations: [AnyCancellable] = [] + private var refreshScheduled = false + private var lastSelection: UUID? + private var revealSelection = true + private var draggedItem: Item? + private var dropItem: Item? + private var isFolderDropTarget = false { + didSet { outline.dropFrame = isFolderDropTarget ? scrollView.frame : nil; outline.needsDisplay = true } + } + var bottomBarHeight: CGFloat { didSet { if oldValue != bottomBarHeight { needsLayout = true } } } + override var isFlipped: Bool { true } + private var scale: CGFloat { CGFloat(AppSettings.shared.interfaceScale) } + private var metrics: SidebarLayoutMetrics { SidebarLayoutMetrics(fontSize: AppSettings.shared.sidebarFontSize) } + private var fontScale: CGFloat { metrics.fontScale * scale } + + init(manager: TerminalManager, tabDrag: TabSplitDragCoordinator, bottomBarHeight: CGFloat) { + self.manager = manager + self.tabDrag = tabDrag + self.bottomBarHeight = bottomBarHeight + super.init(frame: .zero) + material.material = .sidebar + material.blendingMode = .behindWindow + material.state = .followsWindowActiveState + material.setAccessibilityElement(false) + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.documentView = document + scrollView.contentView.postsBoundsChangedNotifications = true + fpsLabel.font = .monospacedDigitSystemFont(ofSize: 10, weight: .medium) + fpsLabel.textColor = .secondaryLabelColor + fpsLabel.isHidden = true + addSubview(material) + for view in [windowDrag, scrollView, fpsLabel, sidebarButton] { addSubview(view) } + sidebarButton.onAction = { [weak manager] in manager?.toggleLeftSidebar() } + footerButtons = [ + WorkspaceChromeButton(symbol: "plus", label: AppCommand.newProject.title) { [weak manager] in manager?.newProject() }, + WorkspaceChromeButton(symbol: "folder.badge.plus", label: String(localized: "New Group")) { [weak self] in self?.showNewGroupMenu() }, + WorkspaceChromeButton(symbol: "network", label: String(localized: "New SSH Project")) { [weak manager] in manager?.promptForSSHProject() }, + WorkspaceChromeButton(symbol: "bolt", label: String(localized: "Quick Launch (⌘O)")) { [weak manager] in manager?.toggleQuickLaunch() }, + WorkspaceChromeButton(symbol: "exclamationmark.bubble", label: String(localized: "Send Feedback")) { + NSWorkspace.shared.open(URL(string: "https://github.com/wzz6423/zshell/issues/new")!) + }, + WorkspaceChromeButton(symbol: "gearshape", label: String(localized: "Settings (⌘,)")) { SettingsWindowController.shared.show() }, + ] + footerButtons.forEach(addSubview) + addSubview(outline) + outline.setAccessibilityElement(false) + for publisher in [manager.objectWillChange.eraseToAnyPublisher(), + groupStore.objectWillChange.eraseToAnyPublisher(), + AppSettings.shared.objectWillChange.eraseToAnyPublisher(), + Theme.changes.objectWillChange.eraseToAnyPublisher()] { + publisher.receive(on: DispatchQueue.main).sink { [weak self] _ in self?.scheduleRefresh() } + .store(in: &observations) + } + tabDrag.$drag.receive(on: DispatchQueue.main).sink { [weak self] _ in self?.updateDropHighlights() } + .store(in: &observations) + NotificationCenter.default.publisher(for: NSView.boundsDidChangeNotification, object: scrollView.contentView) + .sink { [weak self] _ in self?.publishDropFrames() }.store(in: &observations) + fpsCounter.$fps.sink { [weak self] fps in self?.fpsLabel.stringValue = "\(fps) fps" }.store(in: &observations) + registerForDraggedTypes([.fileURL]) + setAccessibilityElement(false) + refresh() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func scheduleRefresh() { + guard !refreshScheduled else { return } + refreshScheduled = true + DispatchQueue.main.async { [weak self] in + self?.refreshScheduled = false + self?.refresh() + } + } + + func refresh() { + let groupIDs = Set(groupStore.groups.map(\.id)) + var items: [Item] = [.ungrouped] + items += manager.projects.filter { $0.groupID.map { !groupIDs.contains($0) } ?? true }.map { .project($0.id) } + for group in groupStore.groups { + items.append(.group(group.id)) + if !group.isCollapsed { items += manager.projects.filter { $0.groupID == group.id }.map { .project($0.id) } } + } + let valid = Set(items) + for key in rows.keys where !valid.contains(key) { rows.removeValue(forKey: key)?.removeFromSuperview() } + order = items + let shortcuts = Dictionary(uniqueKeysWithValues: manager.visibleSidebarProjects.prefix(9).enumerated().map { + ($0.element.id, "⌘\($0.offset + 1)") + }) + for item in items { + let row = rows[item] ?? WorkspaceItemView(frame: .zero) + if rows[item] == nil { rows[item] = row; document.addSubview(row) } + switch item { + case .ungrouped: + row.apply(title: String(localized: "New Ungrouped Project"), + icon: NSImage(systemSymbolName: "arrow.up.left.and.arrow.down.right", accessibilityDescription: nil), + selected: false, sidebar: true, scale: fontScale) + row.onSelect = { [weak manager] in manager?.newProject() } + row.toolTip = String(localized: "New Ungrouped Project") + row.menuItems = { [weak self] in self?.newGroupMenuItems() ?? [] } + case .project(let id): + guard let project = manager.projects.first(where: { $0.id == id }) else { continue } + configure(row, project: project, shortcut: shortcuts[id]) + case .group(let id): + guard let group = groupStore.group(id: id) else { continue } + configure(row, group: group) + } + row.onDrag = { [weak self] event in self?.updateDrag(item: item, event: event) } + row.onDragEnded = { [weak self] event in self?.finishDrag(item: item, event: event) } + row.onDragCancelled = { [weak self] in self?.cancelDrag() } + row.onNavigate = { [weak self] key in self?.navigate(from: item, key: key) } + } + if lastSelection != manager.selectedProjectID { lastSelection = manager.selectedProjectID; revealSelection = true } + let dark = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + material.isHidden = !Theme.isDefault(dark: dark) + outline.drawsOuterEdge = material.isHidden + outline.needsDisplay = true + sidebarButton.configure(symbol: "sidebar.left", command: .toggleLeftSidebar, pointSize: 12 * scale) + footerButtons[0].configure(symbol: "plus", command: .newProject, pointSize: 12 * scale) + for (button, symbol) in zip(footerButtons.dropFirst(), ["folder.badge.plus", "network", "bolt", "exclamationmark.bubble", "gearshape"]) { + button.configure(symbol: symbol, label: button.toolTip ?? "", pointSize: 12 * scale) + } + if manager.isFPSCounterVisible, window != nil { fpsCounter.start() } else { fpsCounter.stop() } + updateDropHighlights() + needsLayout = true + needsDisplay = true + } + + private func configure(_ row: WorkspaceItemView, project: Project, shortcut: String?) { + let subtitle: String? + if project.sessions.count > 1 { subtitle = String(localized: "\(project.sessions.count) sessions") } + else { subtitle = project.selectedSession?.directoryLabel } + row.apply(title: project.name, subtitle: subtitle, + icon: NSImage(systemSymbolName: project.isRemote ? "network" : "folder", accessibilityDescription: nil), + selected: project.id == manager.selectedProjectID, pinned: project.isPinned, + marker: project.markerColor, rollup: project.agentRollup, sidebar: true, + indent: project.groupID == nil ? 0 : 12 * scale, scale: fontScale, shortcut: shortcut, + actionLabel: String(localized: "Close Project"), action: { [weak manager, weak project] in + if let project { manager?.close(project) } + }) + row.toolTip = [project.name, project.customDirectory ?? subtitle].compactMap { $0 }.joined(separator: "\n") + row.onSelect = { [weak manager, weak project] in if let project { manager?.selectedProjectID = project.id } } + row.onRename = { [weak row, weak project] in + guard let project else { return } + row?.beginRename(value: project.name) { [weak project] name in project?.customName = Project.normalizedCustomName(name) } + } + row.menuItems = { [weak self, weak row, weak project] in + guard let self, let project else { return [] } + return self.projectMenu(project, row: row) + } + } + + private func configure(_ row: WorkspaceItemView, group: ProjectGroup) { + let count = manager.projects.filter { $0.groupID == group.id }.count + row.apply(title: group.name, + icon: NSImage(systemSymbolName: group.folderPath == nil ? "tray.full" : "folder", accessibilityDescription: nil), + selected: manager.selectedProject?.groupID == group.id, group: true, collapsed: group.isCollapsed, + count: count, sidebar: true, scale: fontScale, actionSymbol: "plus", + actionLabel: String(localized: "New Project in Group"), action: { [weak manager, weak groupStore] in + guard let current = groupStore?.group(id: group.id) else { return } + manager?.newProject(in: current) + }) + row.toolTip = [group.name, group.folderPath].compactMap { $0 }.joined(separator: "\n") + row.onSelect = { [weak groupStore] in + guard var current = groupStore?.group(id: group.id) else { return } + current.isCollapsed.toggle() + groupStore?.update(current) + } + row.onRename = { [weak row, weak groupStore] in + guard let current = groupStore?.group(id: group.id) else { return } + row?.beginRename(value: current.name) { [weak groupStore] name in + guard let name = Project.normalizedCustomName(name), + var current = groupStore?.group(id: group.id) else { return } + current.name = name + groupStore?.update(current) + } + } + row.menuItems = { [weak self, weak row] in + guard let self, let current = self.groupStore.group(id: group.id) else { return [] } + var items: [AppKitContextMenuItem] = [ + .action(title: String(localized: "New Project in Group")) { self.manager.newProject(in: current) }, + .action(title: String(localized: "Rename…")) { row?.onRename?() }, + .action(title: String(localized: current.isCollapsed ? "Expand Group" : "Collapse Group")) { row?.onSelect?() }, + ] + if current.folderPath != nil { + items.append(.action(title: String(localized: "Change Folder…")) { [weak self] in self?.changeFolder(group: current) }) + } + items += [.separator, .action(title: String(localized: "Remove Group")) { self.manager.deleteProjectGroup(current) }] + return items + } + } + + override func layout() { + super.layout() + material.frame = bounds + outline.frame = bounds + outline.footerHeight = bottomBarHeight + outline.needsDisplay = true + let headerHeight: CGFloat = 38 + let buttonSize = min(34, max(24, 24 * scale)) + sidebarButton.frame = NSRect(x: bounds.width - buttonSize - 8, y: (headerHeight - buttonSize) / 2, width: buttonSize, height: buttonSize) + windowDrag.frame = NSRect(x: 0, y: 0, width: sidebarButton.frame.minX, height: headerHeight) + fpsLabel.isHidden = !manager.isFPSCounterVisible || bounds.width < 210 + fpsLabel.frame = NSRect(x: sidebarButton.frame.minX - 55, y: 12, width: 52, height: 16) + let footerY = max(headerHeight, bounds.height - bottomBarHeight) + scrollView.frame = NSRect(x: 0, y: headerHeight, width: bounds.width, height: max(0, footerY - headerHeight)) + let side = min(max(24, 26 * scale), max(0, (bounds.width - 16) / 6)) + for (index, button) in footerButtons.enumerated() { + let x = index < 4 ? 8 + CGFloat(index) * side : bounds.width - 8 - CGFloat(6 - index) * side + button.frame = NSRect(x: x, y: footerY + (bottomBarHeight - side) / 2, width: side, height: side) + } + var y: CGFloat = 7 + let rowWidth = max(0, scrollView.contentSize.width - 16) + for item in order { + guard let row = rows[item] else { continue } + let height: CGFloat + switch item { + case .project: height = max(38, ceil(31 * fontScale + 8)) + case .group: height = max(28, ceil(21 * fontScale + 6)); y += 5 + case .ungrouped: height = max(26, ceil(19 * fontScale + 6)) + } + row.frame = NSRect(x: 8, y: y, width: rowWidth, height: height) + y += height + 3 + } + document.frame = NSRect(x: 0, y: 0, width: scrollView.contentSize.width, height: max(scrollView.contentSize.height, y + 6)) + if revealSelection && draggedItem == nil { + revealSelection = false + if let id = manager.selectedProjectID, let row = rows[.project(id)] { row.scrollToVisible(row.bounds) } + } + publishDropFrames() + } + + override func draw(_ dirtyRect: NSRect) { + if material.isHidden { Theme.sidebar.setFill(); bounds.fill() } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { detach() } else { refresh() } + } + + override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance(); refresh() } + + func detach() { + fpsCounter.stop() + tabDrag.updateSidebarFrames(projects: [:], groups: [:], ungrouped: nil) + } + + private func publishDropFrames() { + guard window != nil else { return } + var projects: [UUID: CGRect] = [:], groups: [UUID: CGRect] = [:] + var ungrouped: CGRect? + for (item, row) in rows { + let visible = row.bounds.intersection(row.convert(document.visibleRect, from: document)) + guard !visible.isEmpty else { continue } + let frame = row.workspaceGlobalRect(visible) + switch item { + case .project(let id): projects[id] = frame + case .group(let id): groups[id] = frame + case .ungrouped: ungrouped = frame + } + } + tabDrag.updateSidebarFrames(projects: projects, groups: groups, ungrouped: ungrouped) + } + + private func updateDropHighlights() { + for (item, row) in rows { + let targeted: Bool + switch (item, tabDrag.drag?.sidebarTarget) { + case (.project(let id), .project(let target)): targeted = id == target + case (.group(let id), .newProject(let target)): targeted = id == target + case (.ungrouped, .newProject(nil)): targeted = true + default: targeted = false + } + row.isDropTarget = targeted || (item == dropItem && item != draggedItem) + } + } + + private func item(at event: NSEvent) -> Item? { + let point = convert(event.locationInWindow, from: nil) + guard scrollView.frame.contains(point) else { return nil } + let location = document.convert(event.locationInWindow, from: nil) + return order.first { rows[$0]?.frame.contains(location) == true } + } + + private func updateDrag(item: Item, event: NSEvent) { + guard item != .ungrouped else { return } + draggedItem = item + dropItem = self.item(at: event) + updateDropHighlights() + let point = convert(event.locationInWindow, from: nil) + if scrollView.frame.contains(point) { + var y = scrollView.contentView.bounds.minY + if point.y < scrollView.frame.minY + 20 { y -= 12 } + if point.y > scrollView.frame.maxY - 20 { y += 12 } + let maximum = max(0, document.bounds.height - scrollView.contentSize.height) + scrollView.contentView.scroll(to: NSPoint(x: 0, y: min(max(0, y), maximum))) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + } + + private func finishDrag(item: Item, event: NSEvent) { + let target = self.item(at: event) + if case .project(let id) = item, + let project = manager.projects.first(where: { $0.id == id }) { + switch target { + case .group(let groupID): manager.moveProject(project, to: groupStore.group(id: groupID)) + case .ungrouped: manager.moveProject(project, to: nil) + case .project(let targetID): + if targetID != id, let destination = manager.projects.first(where: { $0.id == targetID }) { + manager.moveProject(project, to: groupStore.group(id: destination.groupID)) + manager.moveProject(id, to: targetID) + } + case nil: break + } + } else if case .group(let id) = item, case .group(let targetID) = target { + groupStore.move(id, to: targetID) + } + cancelDrag() + } + + private func cancelDrag() { + draggedItem = nil + dropItem = nil + updateDropHighlights() + NSCursor.arrow.set() + } + + private func navigate(from item: Item, key: UInt16) { + if case .group(let id) = item, key == 123 || key == 124, + var group = groupStore.group(id: id) { + group.isCollapsed = key == 123 + groupStore.update(group) + return + } + guard let index = order.firstIndex(of: item), !order.isEmpty else { return } + let offset = key == 123 || key == 126 ? -1 : 1 + if let row = rows[order[(index + offset + order.count) % order.count]] { + row.scrollToVisible(row.bounds) + window?.makeFirstResponder(row) + } + } + + private func newGroupMenuItems() -> [AppKitContextMenuItem] { + [ + .action(title: String(localized: "Plain Group")) { [weak self] in self?.createGroup(kind: .plain) }, + .action(title: String(localized: "Folder Group…")) { [weak self] in + self?.pickFolder { [weak self] path in if let path { self?.createGroup(kind: .folder(path: path)) } } + }, + ] + } + + private func showNewGroupMenu() { + let button = footerButtons[1] + menuPresenter.popUp(items: newGroupMenuItems(), at: NSPoint(x: 0, y: 0), in: button) + } + + private func createGroup(kind: ProjectGroup.Kind) { + let group = ProjectGroup(name: ProjectGroup.defaultName(for: kind), kind: kind) + groupStore.add(group) + refresh() + layoutSubtreeIfNeeded() + if let row = rows[.group(group.id)] { + row.scrollToVisible(row.bounds) + DispatchQueue.main.async { [weak row] in row?.onRename?() } + } + } + + private func changeFolder(group: ProjectGroup) { + pickFolder(initial: group.folderPath) { [weak self] path in + guard let self, let path, var current = self.groupStore.group(id: group.id) else { return } + let oldDefault = ProjectGroup.defaultName(for: current.kind) + current.kind = .folder(path: path) + if current.name == oldDefault { current.name = ProjectGroup.defaultName(for: current.kind) } + self.groupStore.update(current) + } + } + + private func projectMenu(_ project: Project, row: WorkspaceItemView?) -> [AppKitContextMenuItem] { + var groups: [AppKitContextMenuItem] = groupStore.groups.map { group in + .action(title: group.name, enabled: project.groupID != group.id) { [weak manager] in manager?.moveProject(project, to: group) } + } + if !groups.isEmpty { groups.append(.separator) } + groups.append(.action(title: String(localized: "Remove from Group"), enabled: project.groupID != nil) { [weak manager] in manager?.moveProject(project, to: nil) }) + var items: [AppKitContextMenuItem] = [ + .action(title: String(localized: project.isPinned ? "Unpin Project" : "Pin Project")) { [weak manager] in manager?.setPinned(!project.isPinned, for: project) }, + .action(title: String(localized: "Rename…")) { [weak row] in row?.onRename?() }, + ] + if project.customName != nil { items.append(.action(title: String(localized: "Use Automatic Title")) { project.customName = nil }) } + items += [.separator, .submenu(title: String(localized: "Move to Group"), items: groups), .separator] + items.append(.action(title: String(localized: "Set Color Marker…")) { ProjectTabColorPanelController.shared.present(project: project) }) + if project.markerColor != nil { items.append(.action(title: String(localized: "Remove Color Marker")) { project.markerColor = nil }) } + items += [.separator, .action(title: String(localized: "Set Project Directory…"), enabled: !project.isRemote) { [weak self] in + self?.pickFolder(initial: project.customDirectory ?? project.selectedSession?.currentDirectoryPath) { path in + if let path { project.customDirectory = path } + } + }] + if project.customDirectory != nil { items.append(.action(title: String(localized: "Use Automatic Directory")) { project.customDirectory = nil }) } + items += [.separator, .action(title: String(localized: "Close Project")) { [weak manager] in manager?.close(project) }] + return items + } + + private func pickFolder(initial: String? = nil, completion: @escaping (String?) -> Void) { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = String(localized: "Choose") + if let initial { panel.directoryURL = URL(fileURLWithPath: initial, isDirectory: true) } + if let window { + panel.beginSheetModal(for: window) { response in completion(response == .OK ? panel.url?.path : nil) } + } else { completion(panel.runModal() == .OK ? panel.url?.path : nil) } + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { updateFolderDrop(sender) } + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { updateFolderDrop(sender) } + override func draggingExited(_ sender: NSDraggingInfo?) { isFolderDropTarget = false; needsDisplay = true } + override func draggingEnded(_ sender: NSDraggingInfo) { isFolderDropTarget = false; needsDisplay = true } + + private func updateFolderDrop(_ sender: NSDraggingInfo) -> NSDragOperation { + let inside = scrollView.frame.contains(convert(sender.draggingLocation, from: nil)) + isFolderDropTarget = inside && !ZshellApplicationDelegate.directories(from: sender.draggingPasteboard).isEmpty + needsDisplay = true + return isFolderDropTarget ? .copy : [] + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + isFolderDropTarget = false + needsDisplay = true + let directories = ZshellApplicationDelegate.directories(from: sender.draggingPasteboard) + guard !directories.isEmpty else { + NSSound.beep() + announce(String(localized: "Only folders can be added as projects.")) + return false + } + let count = manager.openOrFocusDirectories(directories) + announce(count == 0 ? String(localized: "Project already open. Focused it in the sidebar.") : String(localized: "Added folder as a project.")) + return true + } + + private func announce(_ message: String) { + NSAccessibility.post(element: NSApp as Any, notification: .announcementRequested, + userInfo: [.announcement: message, .priority: NSAccessibilityPriorityLevel.medium.rawValue]) + } +} diff --git a/mac/zshell/AppKitSessionTabsView.swift b/mac/zshell/AppKitSessionTabsView.swift new file mode 100644 index 0000000..e47e120 --- /dev/null +++ b/mac/zshell/AppKitSessionTabsView.swift @@ -0,0 +1,516 @@ +// +// AppKitSessionTabsView.swift +// zshell +// + +import AppKit +import Combine +import SwiftUI + +/// The existing workspace mounts one native header; AppKit owns all of its +/// controls, scrolling, hit testing, editing, and tab/group presentation. +struct MainHeaderView: NSViewRepresentable { + let manager: TerminalManager + let tabSplitDrag: TabSplitDragCoordinator + + func makeNSView(context: Context) -> MainHeaderNSView { + MainHeaderNSView(manager: manager, tabDrag: tabSplitDrag) + } + + func updateNSView(_ view: MainHeaderNSView, context: Context) { view.refresh() } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView: MainHeaderNSView, context: Context) -> CGSize? { + CGSize(width: proposal.width ?? 500, height: 38) + } +} + +final class MainHeaderNSView: NSView { + private let manager: TerminalManager + private let tabDrag: TabSplitDragCoordinator + private let strip = SessionTabsNSView(frame: .zero) + private let windowDrag = WorkspaceWindowDragView() + private let leftButton = WorkspaceChromeButton(symbol: "sidebar.left", label: AppCommand.toggleLeftSidebar.title) + private let rightButton = WorkspaceChromeButton(symbol: "sidebar.right", label: AppCommand.toggleRightSidebar.title) + private let zoomButton = WorkspaceChromeButton(symbol: "arrow.down.forward.and.arrow.up.backward", label: String(localized: "Exit Pane Zoom (⇧⌘↩)")) + private var observations: [AnyCancellable] = [] + private var refreshScheduled = false + override var isFlipped: Bool { true } + override var intrinsicContentSize: NSSize { NSSize(width: NSView.noIntrinsicMetric, height: 38) } + + init(manager: TerminalManager, tabDrag: TabSplitDragCoordinator) { + self.manager = manager + self.tabDrag = tabDrag + super.init(frame: .zero) + addSubview(windowDrag) + for view in [strip, leftButton, rightButton, zoomButton] { addSubview(view) } + leftButton.onAction = { [weak manager] in manager?.toggleLeftSidebar() } + rightButton.onAction = { [weak manager] in manager?.toggleSidebar() } + zoomButton.onAction = { [weak manager] in manager?.togglePaneZoom() } + strip.onWidthChange = { [weak self] in self?.needsLayout = true } + for publisher in [manager.objectWillChange.eraseToAnyPublisher(), + AppSettings.shared.objectWillChange.eraseToAnyPublisher(), + Theme.changes.objectWillChange.eraseToAnyPublisher()] { + publisher.receive(on: DispatchQueue.main).sink { [weak self] _ in self?.scheduleRefresh() } + .store(in: &observations) + } + setAccessibilityElement(false) + refresh() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func scheduleRefresh() { + guard !refreshScheduled else { return } + refreshScheduled = true + DispatchQueue.main.async { [weak self] in + self?.refreshScheduled = false + self?.refresh() + } + } + + func refresh() { + let scale = CGFloat(AppSettings.shared.interfaceScale) + leftButton.isHidden = manager.isLeftSidebarVisible + rightButton.isHidden = manager.selectedProject == nil + zoomButton.isHidden = !manager.isPaneZoomed + leftButton.configure(symbol: "sidebar.left", command: .toggleLeftSidebar, pointSize: 12 * scale) + rightButton.configure(symbol: "sidebar.right", command: .toggleRightSidebar, pointSize: 12 * scale) + zoomButton.configure(symbol: "arrow.down.forward.and.arrow.up.backward", label: String(localized: "Exit Pane Zoom (⇧⌘↩)"), pointSize: 12 * scale) + zoomButton.contentTintColor = Theme.accent + strip.configure(manager: manager, project: manager.selectedProject, tabDrag: tabDrag) + strip.isHidden = manager.selectedProject == nil + needsLayout = true + needsDisplay = true + } + + override func layout() { + super.layout() + let scale = CGFloat(AppSettings.shared.interfaceScale) + let buttonSide = min(bounds.height - 4, max(24, 24 * scale)) + let y = (bounds.height - buttonSide) / 2 + var left: CGFloat = manager.isLeftSidebarVisible ? 8 : 78 + if !leftButton.isHidden { + leftButton.frame = NSRect(x: left, y: y, width: buttonSide, height: buttonSide) + left += buttonSide + 6 + } + var right = bounds.width - 8 + for button in [rightButton, zoomButton] where !button.isHidden { + right -= buttonSide + button.frame = NSRect(x: right, y: y, width: buttonSide, height: buttonSide) + right -= 6 + } + let available = max(0, right - left - 32) + let stripWidth = min(strip.preferredWidth, available) + strip.frame = NSRect(x: left, y: 2, width: stripWidth, height: max(0, bounds.height - 4)) + windowDrag.frame = NSRect(x: 0, y: 0, width: bounds.width, height: bounds.height) + } + + override func draw(_ dirtyRect: NSRect) { + Theme.background.setFill() + bounds.fill() + Theme.divider.setFill() + NSRect(x: 0, y: bounds.maxY - 1, width: bounds.width, height: 1).fill() + } + + override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance(); refresh() } +} + +private final class SessionStripScrollView: NSScrollView { + override func scrollWheel(with event: NSEvent) { + guard abs(event.scrollingDeltaY) > abs(event.scrollingDeltaX) else { + super.scrollWheel(with: event) + return + } + let maximum = max(0, (documentView?.bounds.width ?? 0) - contentSize.width) + let delta = event.scrollingDeltaY * (event.hasPreciseScrollingDeltas ? 1 : 12) + contentView.scroll(to: NSPoint(x: min(max(0, contentView.bounds.minX - delta), maximum), y: 0)) + reflectScrolledClipView(contentView) + } +} + +private final class SessionStripDocumentView: NSView { + override var isFlipped: Bool { true } +} + +final class SessionTabsNSView: NSView { + private enum Item: Hashable { case tab(UUID), group(UUID) } + private weak var manager: TerminalManager? + private weak var project: Project? + private weak var tabDrag: TabSplitDragCoordinator? + private let scrollView = SessionStripScrollView() + private let document = SessionStripDocumentView() + private let addButton = WorkspaceChromeButton(symbol: "plus", label: AppCommand.newSession.title) + private let groupButton = WorkspaceChromeButton(symbol: "rectangle.3.group", label: String(localized: "New Tab Group")) + private let leftButton = WorkspaceChromeButton(symbol: "chevron.left", label: String(localized: "Scroll Tabs Left")) + private let rightButton = WorkspaceChromeButton(symbol: "chevron.right", label: String(localized: "Scroll Tabs Right")) + private var rows: [Item: WorkspaceItemView] = [:] + private var order: [Item] = [] + private var contentObservations: [UUID: AnyCancellable] = [:] + private var scrollObservation: AnyCancellable? + private var refreshScheduled = false + private var contentWidth: CGFloat = 0 + private var lastViewportWidth: CGFloat = 0 + private var lastSelection: UUID? + private var revealSelection = true + private var draggedItem: Item? + private var dropItem: Item? + var onWidthChange: (() -> Void)? + override var isFlipped: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + scrollView.drawsBackground = false + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = false + scrollView.horizontalScrollElasticity = .none + scrollView.verticalScrollElasticity = .none + scrollView.contentView.postsBoundsChangedNotifications = true + scrollView.documentView = document + for view in [scrollView, addButton, groupButton, leftButton, rightButton] { addSubview(view) } + addButton.onAction = { [weak self] in self?.project?.newSession() } + groupButton.onAction = { [weak self] in self?.createGroup() } + leftButton.onAction = { [weak self] in self?.scroll(by: -160) } + rightButton.onAction = { [weak self] in self?.scroll(by: 160) } + scrollObservation = NotificationCenter.default.publisher( + for: NSView.boundsDidChangeNotification, object: scrollView.contentView + ).sink { [weak self] _ in self?.updateScrollButtons() } + setAccessibilityElement(false) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + var preferredWidth: CGFloat { contentWidth + controlWidth * 2 + 8 } + private var scale: CGFloat { CGFloat(AppSettings.shared.interfaceScale) } + private var controlWidth: CGFloat { min(34, max(24, 24 * scale)) } + + func configure(manager: TerminalManager, project: Project?, tabDrag: TabSplitDragCoordinator) { + if self.project !== project { + cancelDrag() + rows.values.forEach { $0.removeFromSuperview() } + rows = [:] + order = [] + contentObservations = [:] + lastSelection = nil + scrollView.contentView.scroll(to: .zero) + } + self.manager = manager + self.project = project + self.tabDrag = tabDrag + refresh() + } + + private func scheduleRefresh() { + guard !refreshScheduled else { return } + refreshScheduled = true + DispatchQueue.main.async { [weak self] in + self?.refreshScheduled = false + self?.refresh() + } + } + + private func refresh() { + guard let project else { contentWidth = 0; return } + var items = project.tabs.filter { $0.tabGroupID == nil }.map { Item.tab($0.id) } + for group in project.tabGroups { + items.append(.group(group.id)) + if !group.isCollapsed { + items += project.tabs.filter { $0.tabGroupID == group.id }.map { .tab($0.id) } + } + } + let current = Set(items) + for key in rows.keys where !current.contains(key) { rows.removeValue(forKey: key)?.removeFromSuperview() } + if order != items { revealSelection = true } + order = items + var width: CGFloat = 0 + for item in items { + let row = rows[item] ?? WorkspaceItemView(frame: .zero) + if rows[item] == nil { rows[item] = row; document.addSubview(row) } + switch item { + case .tab(let id): + guard let tab = project.tabs.first(where: { $0.id == id }) else { continue } + configure(row, tab: tab, project: project) + case .group(let id): + guard let group = project.tabGroup(id: id) else { continue } + configure(row, group: group, project: project) + } + row.onDrag = { [weak self] event in self?.updateDrag(item: item, event: event) } + row.onDragEnded = { [weak self] event in self?.finishDrag(item: item, event: event) } + row.onDragCancelled = { [weak self] in self?.cancelDrag() } + row.onNavigate = { [weak self] key in self?.navigate(from: item, key: key) } + row.frame = NSRect(x: width, y: 0, width: row.preferredWidth, height: bounds.height) + width += row.preferredWidth + 3 + } + width = max(0, width - 3) + if width != contentWidth { contentWidth = width; revealSelection = true; onWidthChange?() } + if lastSelection != project.selectedTabID { lastSelection = project.selectedTabID; revealSelection = true } + observeContent(in: project) + addButton.configure(symbol: "plus", command: .newSession, pointSize: 11 * scale) + groupButton.configure(symbol: "rectangle.3.group", label: String(localized: "New Tab Group"), pointSize: 11 * scale) + needsLayout = true + } + + private func configure(_ row: WorkspaceItemView, tab: PaneTab, project: Project) { + let content = tab.focusedContent + let image: NSImage? + if let path = content?.fileIconPath { + image = MaterialFileIcon.image(forPath: path, appearance: effectiveAppearance) + } else if case .browser(let browser) = content, let favicon = browser.favicon { + image = favicon + } else { + image = NSImage(systemSymbolName: content?.systemImage ?? "terminal", accessibilityDescription: nil) + } + row.apply(title: tab.displayTitle ?? String(localized: "Tab"), icon: image, + selected: tab.id == project.selectedTabID, grouped: tab.tabGroupID != nil, + pinned: tab.isPinned, marker: tab.markerColor, + count: tab.allPanes.count > 1 ? tab.allPanes.count : nil, + rollup: tab.agentRollup, dirty: content?.isDirty == true, scale: scale, + action: { [weak project, weak tab] in if let tab { project?.close(tab) } }) + row.toolTip = content?.fileIconPath ?? tab.displayTitle + row.onSelect = { [weak project, weak tab] in if let tab { project?.selectedTabID = tab.id } } + row.onRename = { [weak row, weak tab] in + guard let tab else { return } + row?.beginRename(value: tab.displayTitle ?? "") { [weak tab] name in + tab?.customName = Project.normalizedCustomName(name) + } + } + row.menuItems = { [weak self, weak tab] in + guard let tab else { return [] } + return self?.tabMenu(tab) ?? [] + } + } + + private func configure(_ row: WorkspaceItemView, group: SessionTabGroup, project: Project) { + let members = project.tabs.filter { $0.tabGroupID == group.id } + row.apply(title: group.name, icon: nil, + selected: project.selectedTab?.tabGroupID == group.id, + group: true, collapsed: group.isCollapsed, grouped: true, + count: members.count, scale: scale, actionSymbol: "plus", + actionLabel: String(localized: "New Session in Group"), + action: { [weak project] in project?.newSession(inTabGroup: group.id) }) + row.toolTip = group.name + row.onSelect = { [weak project] in + guard let current = project?.tabGroup(id: group.id) else { return } + project?.setTabGroupCollapsed(!current.isCollapsed, id: group.id) + } + row.onRename = { [weak row, weak project] in + guard let project, let current = project.tabGroup(id: group.id) else { return } + row?.beginRename(value: current.name) { [weak project] name in project?.renameTabGroup(group.id, to: name) } + } + row.menuItems = { [weak row, weak project] in + guard let project, let current = project.tabGroup(id: group.id) else { return [] } + return [ + .action(title: String(localized: "New Session in Group")) { project.newSession(inTabGroup: group.id) }, + .action(title: String(localized: "Rename…")) { row?.onRename?() }, + .action(title: String(localized: current.isCollapsed ? "Expand Group" : "Collapse Group")) { + project.setTabGroupCollapsed(!current.isCollapsed, id: group.id) + }, + .separator, + .action(title: String(localized: "Remove Group")) { project.removeTabGroup(group.id) }, + ] + } + } + + private func observeContent(in project: Project) { + let contents = project.tabs.flatMap(\.allContents) + let ids = Set(contents.map(\.id)) + for id in contentObservations.keys where !ids.contains(id) { contentObservations[id] = nil } + for content in contents where contentObservations[content.id] == nil { + let publisher: ObservableObjectPublisher + switch content { + case .session(let session): publisher = session.objectWillChange + case .file(let file): publisher = file.objectWillChange + case .browser(let browser): publisher = browser.objectWillChange + case .diff(let diff): publisher = diff.objectWillChange + } + contentObservations[content.id] = publisher.receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.scheduleRefresh() } + } + } + + override func layout() { + super.layout() + let controls = controlWidth * 2 + 8 + let available = max(0, bounds.width - controls) + let overflow = contentWidth > available + 0.5 + let arrowWidth: CGFloat = overflow ? 20 : 0 + leftButton.isHidden = !overflow + rightButton.isHidden = !overflow + leftButton.frame = NSRect(x: 0, y: 0, width: arrowWidth, height: bounds.height) + let viewportWidth = max(0, available - arrowWidth * 2) + scrollView.frame = NSRect(x: arrowWidth, y: 0, width: viewportWidth, height: bounds.height) + rightButton.frame = NSRect(x: arrowWidth + viewportWidth, y: 0, width: arrowWidth, height: bounds.height) + addButton.frame = NSRect(x: available + 4, y: 0, width: controlWidth, height: bounds.height) + groupButton.frame = NSRect(x: available + 4 + controlWidth, y: 0, width: controlWidth, height: bounds.height) + document.frame = NSRect(x: 0, y: 0, width: max(viewportWidth, contentWidth), height: bounds.height) + for row in rows.values { row.setFrameSize(NSSize(width: row.frame.width, height: bounds.height)) } + if lastViewportWidth != viewportWidth { lastViewportWidth = viewportWidth; revealSelection = true } + scroll(by: 0) + if revealSelection && draggedItem == nil { + revealSelection = false + revealSelectedRow() + } + updateScrollButtons() + } + + private func revealSelectedRow() { + guard let project, let tab = project.selectedTab else { return } + let item: Item = project.tabGroup(id: tab.tabGroupID)?.isCollapsed == true + ? .group(tab.tabGroupID!) : .tab(tab.id) + guard let row = rows[item] else { return } + row.scrollToVisible(row.bounds.insetBy(dx: -3, dy: 0)) + } + + private func scroll(by delta: CGFloat) { + let maximum = max(0, contentWidth - scrollView.contentSize.width) + let x = min(max(0, scrollView.contentView.bounds.minX + delta), maximum) + scrollView.contentView.scroll(to: NSPoint(x: x, y: 0)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + + private func updateScrollButtons() { + leftButton.isEnabled = scrollView.contentView.bounds.minX > 0.5 + rightButton.isEnabled = scrollView.contentView.bounds.maxX < contentWidth - 0.5 + } + + private func createGroup() { + guard let project else { return } + let group = project.createTabGroup(containing: project.selectedTab) + refresh() + layoutSubtreeIfNeeded() + rows[.group(group.id)]?.onRename?() + } + + private func item(at event: NSEvent) -> Item? { + let point = convert(event.locationInWindow, from: nil) + guard scrollView.frame.contains(point) else { return nil } + let location = document.convert(event.locationInWindow, from: nil) + return order.first { rows[$0]?.frame.contains(location) == true } + } + + private func updateDrag(item: Item, event: NSEvent) { + guard let project, let manager, let tabDrag else { return } + draggedItem = item + dropItem = self.item(at: event) + for (key, row) in rows { row.isDropTarget = key == dropItem && key != item } + if case .tab(let id) = item { + tabDrag.update(sourceTabID: id, location: workspaceGlobalPoint(event), in: project, manager: manager) + } + let point = convert(event.locationInWindow, from: nil) + if scrollView.frame.contains(point) { + if point.x < scrollView.frame.minX + 18 { scroll(by: -12) } + if point.x > scrollView.frame.maxX - 18 { scroll(by: 12) } + } + } + + private func finishDrag(item: Item, event: NSEvent) { + guard let project else { cancelDrag(); return } + let target = self.item(at: event) + switch (item, target) { + case (.group(let source), .group(let destination)): + project.moveTabGroup(source, to: destination) + case (.tab(let source), .group(let destination)): + project.moveTab(source, toGroup: destination) + case (.tab(let source), .tab(let destination)): + project.moveTab(source, to: destination) + case (.tab(let source), nil): + let point = convert(event.locationInWindow, from: nil) + let documentPoint = document.convert(event.locationInWindow, from: nil) + if scrollView.frame.contains(point), documentPoint.x > contentWidth + 8 { + project.moveTab(source, toGroup: nil) + } else { + if let manager { + tabDrag?.update(sourceTabID: source, location: workspaceGlobalPoint(event), in: project, manager: manager) + } + tabDrag?.commit() + } + default: break + } + cancelDrag() + refresh() + } + + private func cancelDrag() { + draggedItem = nil + dropItem = nil + tabDrag?.cancel() + rows.values.forEach { $0.isDropTarget = false } + NSCursor.arrow.set() + } + + private func navigate(from item: Item, key: UInt16) { + guard let index = order.firstIndex(of: item), !order.isEmpty else { return } + let offset = key == 123 || key == 126 ? -1 : 1 + let next = order[(index + offset + order.count) % order.count] + guard let row = rows[next] else { return } + row.scrollToVisible(row.bounds) + window?.makeFirstResponder(row) + } + + private func tabMenu(_ tab: PaneTab) -> [AppKitContextMenuItem] { + guard let project, let manager else { return [] } + var items: [AppKitContextMenuItem] = [ + .action(title: String(localized: tab.isPinned ? "Unpin Tab" : "Pin Tab")) { project.setPinned(!tab.isPinned, for: tab) }, + .action(title: String(localized: "Rename…")) { [weak self] in self?.rows[.tab(tab.id)]?.onRename?() }, + ] + if tab.customName != nil { items.append(.action(title: String(localized: "Use Automatic Title")) { tab.customName = nil }) } + var groupItems: [AppKitContextMenuItem] = [ + .action(title: String(localized: "New Tab Group")) { [weak self] in + let group = project.createTabGroup(containing: tab) + self?.refresh() + DispatchQueue.main.async { [weak self] in self?.rows[.group(group.id)]?.onRename?() } + }, + ] + if !project.tabGroups.isEmpty { groupItems.append(.separator) } + groupItems += project.tabGroups.map { group in + .action(title: group.name, enabled: tab.tabGroupID != group.id) { project.moveTab(tab.id, toGroup: group.id) } + } + if tab.tabGroupID != nil { + groupItems.append(.separator) + groupItems.append(.action(title: String(localized: "Remove from Group")) { project.moveTab(tab.id, toGroup: nil) }) + } + items += [.separator, .submenu(title: String(localized: "Move to Group"), items: groupItems), .separator] + items.append(.action(title: String(localized: "Set Color Marker…")) { ProjectTabColorPanelController.shared.present(tab: tab) }) + if tab.markerColor != nil { items.append(.action(title: String(localized: "Remove Color Marker")) { tab.markerColor = nil }) } + if case .file(let file) = tab.focusedContent { + items.append(.action(title: String(localized: "Reveal in Finder")) { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: file.path)]) }) + items.append(.action(title: String(localized: "Copy Absolute Path")) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(file.path, forType: .string) + }) + } + if case .browser(let browser) = tab.focusedContent, !browser.urlString.isEmpty { + items.append(.action(title: String(localized: "Open in Default Browser"), enabled: browser.shareURL != nil) { browser.openInDefaultBrowser() }) + items.append(.action(title: String(localized: "Copy Address")) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(browser.urlString, forType: .string) + }) + } + let destinations = manager.tabMoveDestinations(for: tab.id, in: project.id) + var moveItems: [AppKitContextMenuItem] = [ + .action(title: String(localized: "New Ungrouped Project"), enabled: tab.diffs.isEmpty) { [weak self] in + let result = manager.moveTabToNewProject(id: tab.id, from: project.id, in: nil) + if let failure = result.failure { self?.tabDrag?.presentMoveFailure(failure) } + }, + ] + if !destinations.isEmpty { moveItems.append(.separator) } + moveItems += destinations.map { destination in + let title = destination.windowTitle.map { "\(destination.title) — \($0)" } ?? destination.title + return .action(title: title, enabled: destination.isEnabled) { [weak self] in + let result = manager.moveTab(id: tab.id, from: project.id, to: destination.projectID, in: destination.managerID) + if let failure = result.failure { self?.tabDrag?.presentMoveFailure(failure) } + } + } + items += [.separator, .submenu(title: String(localized: "Move Tab to Project"), items: moveItems), .separator] + items += [ + .action(title: String(localized: "Close")) { project.close(tab) }, + .action(title: String(localized: "Close Others"), enabled: project.tabs.count > 1) { project.closeOthers(tab) }, + .action(title: String(localized: "Close Tabs to the Right"), enabled: project.tabs.last?.id != tab.id) { project.closeToRight(of: tab) }, + .separator, + .action(title: String(localized: "Close Files"), enabled: project.hasFiles) { project.closeFiles() }, + .action(title: String(localized: "Close Diffs"), enabled: project.hasDiffs) { project.closeDiffs() }, + .separator, + .action(title: String(localized: "Close All")) { project.closeAll() }, + ] + return items + } +} diff --git a/mac/zshell/CommandPaletteView.swift b/mac/zshell/CommandPaletteView.swift index f467a81..17e46f2 100644 --- a/mac/zshell/CommandPaletteView.swift +++ b/mac/zshell/CommandPaletteView.swift @@ -111,6 +111,7 @@ private final class PalettePointerSelectionController: ObservableObject { struct CommandPaletteView: View { @ObservedObject var manager: TerminalManager @ObservedObject private var themeChanges = Theme.changes + @ObservedObject private var settings = AppSettings.shared @State private var query = "" @State private var selection = 0 @@ -157,7 +158,7 @@ struct CommandPaletteView: View { private var commands: [PaletteCommand] { var items: [PaletteCommand] = [ - PaletteCommand(id: "new-session", title: "New Session", systemImage: "terminal", shortcut: "⌘T") { + PaletteCommand(id: "new-session", title: "New Session", systemImage: "terminal", shortcut: settings.commandShortcut(for: .newSession).displayString) { manager.newSession() }, PaletteCommand(id: "new-browser-tab", title: "New Browser Tab", systemImage: "globe") { @@ -166,7 +167,7 @@ struct CommandPaletteView: View { PaletteCommand(id: "new-browser-pane", title: "New Browser Pane", systemImage: "globe") { manager.newBrowserPane() }, - PaletteCommand(id: "clear-terminal", title: "Clear Terminal", systemImage: "eraser", shortcut: "⌘K") { + PaletteCommand(id: "clear-terminal", title: "Clear Terminal", systemImage: "eraser", shortcut: settings.commandShortcut(for: .clearTerminal).displayString) { manager.clearActiveTerminal() }, PaletteCommand(id: "toggle-prompt-queue", title: "Toggle Prompt Queue", systemImage: "list.bullet.rectangle", shortcut: "⇧⌘M") { @@ -220,28 +221,28 @@ struct CommandPaletteView: View { PaletteCommand(id: "resize-pane-right", title: "Resize Pane Right", systemImage: "arrow.right.to.line", shortcut: "⌃⌘→") { manager.resizePaneRight() }, - PaletteCommand(id: "new-project", title: "New Project", systemImage: "folder.badge.plus", shortcut: "⌘N") { + PaletteCommand(id: "new-project", title: "New Project", systemImage: "folder.badge.plus", shortcut: settings.commandShortcut(for: .newProject).displayString) { manager.newProject() }, PaletteCommand(id: "new-ssh-project", title: "New SSH Project…", systemImage: "network") { manager.promptForSSHProject() }, - PaletteCommand(id: "close-tab", title: "Close Tab", systemImage: "xmark.square", shortcut: "⌘W") { + PaletteCommand(id: "close-tab", title: "Close Tab", systemImage: "xmark.square", shortcut: settings.commandShortcut(for: .closePane).displayString) { manager.closeSelectedTab() }, PaletteCommand(id: "save-file", title: "Save File", systemImage: "square.and.arrow.down", shortcut: "⌘S") { manager.saveSelectedFile() }, - PaletteCommand(id: "toggle-left-sidebar", title: "Toggle Left Sidebar", systemImage: "sidebar.left", shortcut: "⌘B") { + PaletteCommand(id: "toggle-left-sidebar", title: "Toggle Left Sidebar", systemImage: "sidebar.left", shortcut: settings.commandShortcut(for: .toggleLeftSidebar).displayString) { manager.toggleLeftSidebar() }, - PaletteCommand(id: "toggle-sidebar", title: "Toggle Right Sidebar", systemImage: "sidebar.right", shortcut: "⇧⌘B") { + PaletteCommand(id: "toggle-sidebar", title: "Toggle Right Sidebar", systemImage: "sidebar.right", shortcut: settings.commandShortcut(for: .toggleRightSidebar).displayString) { manager.toggleSidebar() }, - PaletteCommand(id: "toggle-files", title: "Toggle Files Panel", systemImage: "doc.text", shortcut: "⇧⌘E") { + PaletteCommand(id: "toggle-files", title: "Toggle Files Panel", systemImage: "doc.text", shortcut: settings.commandShortcut(for: .toggleFilesPanel).displayString) { manager.togglePanel(.files) }, - PaletteCommand(id: "toggle-git", title: "Toggle Git Panel", systemImage: "arrow.triangle.branch", shortcut: "⇧⌘G") { + PaletteCommand(id: "toggle-git", title: "Toggle Git Panel", systemImage: "arrow.triangle.branch", shortcut: settings.commandShortcut(for: .toggleGitPanel).displayString) { manager.togglePanel(.git) }, PaletteCommand(id: "toggle-info", title: "Toggle Info Panel", systemImage: "info.circle", shortcut: "⇧⌘I") { @@ -273,10 +274,10 @@ struct CommandPaletteView: View { PaletteCommand(id: "prev-tab", title: "Previous Tab", systemImage: "arrow.left", shortcut: "⇧⌘[") { manager.selectPreviousTab() }, - PaletteCommand(id: "next-project", title: "Next Project", systemImage: "arrow.right.square", shortcut: "⌥⌘]") { + PaletteCommand(id: "next-project", title: "Next Project", systemImage: "arrow.right.square", shortcut: settings.commandShortcut(for: .nextProject).displayString) { manager.selectNextProject() }, - PaletteCommand(id: "prev-project", title: "Previous Project", systemImage: "arrow.left.square", shortcut: "⌥⌘[") { + PaletteCommand(id: "prev-project", title: "Previous Project", systemImage: "arrow.left.square", shortcut: settings.commandShortcut(for: .previousProject).displayString) { manager.selectPreviousProject() }, ] @@ -289,15 +290,17 @@ struct CommandPaletteView: View { ) } - for (index, project) in manager.projects.enumerated() where project.id != manager.selectedProjectID { + let visibleProjects = manager.visibleSidebarProjects + for project in manager.projects where project.id != manager.selectedProjectID { + let index = visibleProjects.firstIndex { $0.id == project.id } items.append( PaletteCommand( id: "switch-project-\(project.id)", title: "Switch to Project: \(project.name)", systemImage: "folder", - shortcut: index < 9 ? "⌘\(index + 1)" : nil + shortcut: index.flatMap { $0 < 9 ? "⌘\($0 + 1)" : nil } ) { - manager.selectProject(index: index) + manager.selectedProjectID = project.id } ) } diff --git a/mac/zshell/CommandShortcuts.swift b/mac/zshell/CommandShortcuts.swift index 3dbcd9b..c1721aa 100644 --- a/mac/zshell/CommandShortcuts.swift +++ b/mac/zshell/CommandShortcuts.swift @@ -106,19 +106,7 @@ struct CommandShortcut: Equatable { /// codes can't be derived for every character, so `nil` just means the /// Quick Terminal overlap check doesn't apply. var ansiKeyCode: UInt16? { - guard character.unicodeScalars.count == 1, - let scalar = character.unicodeScalars.first else { return nil } - switch scalar.value { - case UInt32(UInt8(ascii: "a"))...UInt32(UInt8(ascii: "z")): - return UInt16(kVK_ANSI_A) + UInt16(scalar.value - UInt32(UInt8(ascii: "a"))) - case UInt32(UInt8(ascii: "1"))...UInt32(UInt8(ascii: "9")): - return UInt16(kVK_ANSI_1) + UInt16(scalar.value - UInt32(UInt8(ascii: "1"))) - case UInt32(UInt8(ascii: "0")): - return UInt16(kVK_ANSI_0) - case UInt32(UInt8(ascii: "[")): return UInt16(kVK_ANSI_LeftBracket) - case UInt32(UInt8(ascii: "]")): return UInt16(kVK_ANSI_RightBracket) - default: return nil - } + QuickTerminalShortcut.keyCode(for: character).map(UInt16.init) } /// Menus can't carry bare whitespace, and function keys arrive as diff --git a/mac/zshell/ContentView.swift b/mac/zshell/ContentView.swift index aee0090..626749b 100644 --- a/mac/zshell/ContentView.swift +++ b/mac/zshell/ContentView.swift @@ -6,17 +6,27 @@ import Combine import SwiftUI -/// Coordinates the direct tab-strip drag with the mounted pane layout. A -/// reference object keeps the latest global pointer location and pane frames -/// available synchronously when the strip receives its drag-ended callback. +/// A sidebar destination for a live tab drag. Dropping on an existing project +/// transfers the tab into it; dropping on a group header or the explicit +/// ungrouped target pulls the tab out into a new project. +enum TabSidebarDropTarget: Equatable { + case project(UUID) + case newProject(groupID: UUID?) +} + +/// Coordinates a direct tab-strip drag across the mounted pane layout and the +/// project sidebar. A reference object keeps the latest global pointer location +/// and destination frames available synchronously when mouse-up arrives. @MainActor final class TabSplitDragCoordinator: ObservableObject { struct Drag { let sourceTabID: UUID + let sourceProjectID: UUID let location: CGPoint let targetTabID: UUID? let targetPaneID: UUID? let edge: PaneDropEdge? + let sidebarTarget: TabSidebarDropTarget? let title: String let systemImage: String let fileIconPath: String? @@ -26,11 +36,21 @@ final class TabSplitDragCoordinator: ObservableObject { @Published private(set) var drag: Drag? private weak var project: Project? + private weak var manager: TerminalManager? private var renderedTabID: UUID? private var paneFrames: [UUID: CGRect] = [:] + private var sidebarProjectFrames: [UUID: CGRect] = [:] + private var sidebarGroupFrames: [UUID: CGRect] = [:] + private var sidebarUngroupedFrame: CGRect? - func update(sourceTabID: UUID, location: CGPoint, in project: Project) { + func update( + sourceTabID: UUID, + location: CGPoint, + in project: Project, + manager: TerminalManager + ) { self.project = project + self.manager = manager drag = resolvedDrag( sourceTabID: sourceTabID, location: location, @@ -38,6 +58,28 @@ final class TabSplitDragCoordinator: ObservableObject { ) } + /// Sidebar geometry is reported independently from the tab strip. Re-resolve + /// an active drag whenever grouping, collapse, scrolling, or resizing moves + /// one of the destinations under a stationary pointer. + func updateSidebarFrames( + projects: [UUID: CGRect], + groups: [UUID: CGRect], + ungrouped: CGRect? + ) { + let changed = sidebarProjectFrames != projects + || sidebarGroupFrames != groups + || sidebarUngroupedFrame != ungrouped + sidebarProjectFrames = projects + sidebarGroupFrames = groups + sidebarUngroupedFrame = ungrouped + guard changed, let drag, let project else { return } + self.drag = resolvedDrag( + sourceTabID: drag.sourceTabID, + location: drag.location, + in: project + ) + } + /// Pane frames are reported by the currently mounted layout, including a /// single full-bleed pane. Re-resolve an active drag because a resize or /// newly created split can change the quadrant under a stationary cursor. @@ -60,7 +102,7 @@ final class TabSplitDragCoordinator: ObservableObject { } func commit() { - guard let drag, let project else { + guard let drag, let project, let manager else { cancel() return } @@ -71,15 +113,36 @@ final class TabSplitDragCoordinator: ObservableObject { location: drag.location, in: project ) - if let targetTabID = resolved.targetTabID, - let targetPaneID = resolved.targetPaneID, - let edge = resolved.edge { - project.moveTab( - resolved.sourceTabID, - into: targetTabID, - toward: edge, - beside: targetPaneID + let result: TerminalManager.TabMoveResult? + switch resolved.sidebarTarget { + case .project(let destinationProjectID): + result = manager.moveTab( + id: resolved.sourceTabID, + from: resolved.sourceProjectID, + to: destinationProjectID, + in: ObjectIdentifier(manager) + ) + case .newProject(let groupID): + result = manager.moveTabToNewProject( + id: resolved.sourceTabID, + from: resolved.sourceProjectID, + in: ProjectGroupStore.shared.group(id: groupID) ) + case nil: + result = nil + if let targetTabID = resolved.targetTabID, + let targetPaneID = resolved.targetPaneID, + let edge = resolved.edge { + project.moveTab( + resolved.sourceTabID, + into: targetTabID, + toward: edge, + beside: targetPaneID + ) + } + } + if let failure = result?.failure { + presentMoveFailure(failure) } cancel() } @@ -87,6 +150,7 @@ final class TabSplitDragCoordinator: ObservableObject { func cancel() { drag = nil project = nil + manager = nil } private func resolvedDrag( @@ -105,6 +169,7 @@ final class TabSplitDragCoordinator: ObservableObject { targetTabID != sourceTabID, renderedTabID == targetTabID, let targetTab = project.selectedTab, + source.isPinned == targetTab.isPinned, let hit = paneFrames.first(where: { $0.value.contains(location) }), let targetPane = targetTab.allPanes.first(where: { $0.id == hit.key }), !targetPane.content.isDiff { @@ -112,12 +177,29 @@ final class TabSplitDragCoordinator: ObservableObject { edge = dropEdge(at: location, in: hit.value) } + let sidebarTarget: TabSidebarDropTarget? + if let destination = sidebarProjectFrames.first(where: { + $0.key != project.id && $0.value.contains(location) + })?.key { + sidebarTarget = .project(destination) + } else if let groupID = sidebarGroupFrames.first(where: { + $0.value.contains(location) + })?.key { + sidebarTarget = .newProject(groupID: groupID) + } else if sidebarUngroupedFrame?.contains(location) == true { + sidebarTarget = .newProject(groupID: nil) + } else { + sidebarTarget = nil + } + return Drag( sourceTabID: sourceTabID, + sourceProjectID: project.id, location: location, - targetTabID: targetPaneID == nil ? nil : targetTabID, - targetPaneID: targetPaneID, - edge: edge, + targetTabID: sidebarTarget == nil && targetPaneID != nil ? targetTabID : nil, + targetPaneID: sidebarTarget == nil ? targetPaneID : nil, + edge: sidebarTarget == nil ? edge : nil, + sidebarTarget: sidebarTarget, title: source?.displayTitle ?? sourceContent?.title ?? String(localized: "Tab"), systemImage: sourceContent?.systemImage ?? "terminal", fileIconPath: sourceContent?.fileIconPath, @@ -125,6 +207,19 @@ final class TabSplitDragCoordinator: ObservableObject { ) } + func presentMoveFailure(_ failure: TerminalManager.TabMoveFailure) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "Couldn’t Move Tab") + alert.informativeText = failure.message + alert.addButton(withTitle: String(localized: "OK")) + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } + private func dropEdge(at location: CGPoint, in frame: CGRect) -> PaneDropEdge { let dx = (location.x - frame.midX) / max(frame.width, 1) let dy = (location.y - frame.midY) / max(frame.height, 1) @@ -184,6 +279,7 @@ struct ContentView: View { if manager.isLeftSidebarVisible { SidebarView( manager: manager, + tabDrag: tabSplitDrag, bottomBarHeight: bottomToolbarHeight ) } @@ -965,851 +1061,3 @@ private struct InstantPopoverPresenter: NSViewRepresentabl coordinator.dismantle() } } - -/// Slim bar above the terminal: the selected project's sessions as -/// horizontal tabs, with sidebar controls at the outer edges. Doubles as -/// window-drag space. -private struct MainHeaderView: View { - @ObservedObject var manager: TerminalManager - @ObservedObject var tabSplitDrag: TabSplitDragCoordinator - @ObservedObject private var settings = AppSettings.shared - @ObservedObject private var themeChanges = Theme.changes - - private var scale: CGFloat { CGFloat(settings.interfaceScale) } - - /// Keep an always-available grab target beside the trailing controls, - /// even when the session strip is full. - private let minimumWindowDragWidth: CGFloat = 40 - - /// With the left sidebar hidden the header slides under the window's - /// traffic-light buttons, so inset its content to clear them. - private var leadingInset: CGFloat { - manager.isLeftSidebarVisible ? 8 : 78 - } - - /// A hidden sidebar moves its toggle into this header. Reserve the - /// button and its following HStack spacing before sizing the tab strip. - private var hiddenLeftSidebarControlWidth: CGFloat { - manager.isLeftSidebarVisible ? 0 : 32 - } - - var body: some View { - GeometryReader { geo in - HStack(spacing: 0) { - if !manager.isLeftSidebarVisible { - ChromeIconButton( - systemImage: "sidebar.left", - tooltip: "Toggle Left Sidebar (⌘B)", - tooltipAlignment: .leading - ) { - manager.toggleLeftSidebar() - } - .padding(.trailing, 8) - } - if let project = manager.selectedProject { - // Reserve the trailing controls so the session strip's - // inline new-session button stays clear of them. - SessionTabsView( - manager: manager, - project: project, - tabSplitDrag: tabSplitDrag, - maxStripWidth: max( - 0, - geo.size.width - leadingInset - hiddenLeftSidebarControlWidth - - 66 - (manager.isPaneZoomed ? 32 : 0) - ) - ) - } - WindowDragArea() - .frame(maxWidth: .infinity) - HStack(spacing: 8) { - // Zoom indicator: only visible while the selected tab has a - // zoomed pane. Styled like the sidebar toggle next to it, with - // the accent tint marking the active state. Click restores the - // layout. - if manager.isPaneZoomed { - Button { - manager.togglePaneZoom() - } label: { - Image(systemName: "arrow.down.forward.and.arrow.up.backward") - .font(.system(size: 12 * scale, weight: .medium)) - .foregroundStyle(Color(nsColor: Theme.accent)) - .frame(width: 24 * scale, height: 24 * scale) - .contentShape(RoundedRectangle(cornerRadius: 6)) - } - .buttonStyle(.plain) - .tooltip("Exit Pane Zoom (⇧⌘↩)", edge: .below, alignment: .trailing) - } - // No project means the sidebar has nothing to show, so drop - // its toggle too — matching the panel collapsing itself. - if manager.selectedProject != nil { - ChromeIconButton( - systemImage: "sidebar.right", - tooltip: "Toggle Right Sidebar (⇧⌘B)" - ) { - manager.toggleSidebar() - } - } - } - .padding(.leading, 8) - } - .padding(.leading, leadingInset) - .padding(.trailing, 8) - .frame(height: geo.size.height) - } - .frame(height: 38) - .background(Color(nsColor: Theme.background)) - .overlay(alignment: .bottom) { - Rectangle() - .fill(Color(nsColor: Theme.divider)) - .frame(height: 1) - } - } -} - -/// Horizontal tabs for one project — terminal sessions and open files — -/// plus a "+" button. -private struct SessionTabsView: View { - private let fadeWidth: CGFloat = 20 - private let tabSpacing: CGFloat = 3 - - @ObservedObject var manager: TerminalManager - @ObservedObject var project: Project - @ObservedObject var tabSplitDrag: TabSplitDragCoordinator - @ObservedObject private var settings = AppSettings.shared - let maxStripWidth: CGFloat - @State private var overflow = StripOverflow() - @State private var scrollGeometry = StripScrollGeometry() - @State private var tabFrames: [UUID: CGRect] = [:] - @State private var tabSizes: [UUID: CGSize] = [:] - /// Tab currently showing the inline rename field, if any. - @State private var renamingTabID: UUID? - - private var scale: CGFloat { CGFloat(settings.interfaceScale) } - - /// Which edges have off-screen tabs, i.e. where to show a fade hint. - private struct StripOverflow: Equatable { - var left = false - var right = false - } - - private struct StripScrollGeometry: Equatable { - var contentOffsetX: CGFloat = 0 - var containerWidth: CGFloat = 0 - var contentWidth: CGFloat = 0 - } - - var body: some View { - HStack(spacing: 4) { - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: tabSpacing) { - ForEach(project.tabs) { tab in - PaneTabItem( - tab: tab, - isSelected: tab.id == project.selectedTabID, - select: { project.selectedTabID = tab.id }, - close: { project.close(tab) }, - renamingTabID: $renamingTabID - ) - .background { - AppKitContextMenuMonitor(items: tabContextMenuItems(for: tab)) - } - .background { - GeometryReader { proxy in - Color.clear.preference( - key: TabFramePreferenceKey.self, - value: [tab.id: proxy.frame(in: .global)] - ) - } - } - .opacity(tabSplitDrag.drag?.sourceTabID == tab.id ? 0.65 : 1) - // Masked to .subviews while renaming so dragging in the - // text field selects text instead of reordering the tab. - .highPriorityGesture( - DragGesture(minimumDistance: 4, coordinateSpace: .global) - .onChanged { value in - updateTabDrag(source: tab.id, location: value.location) - } - .onEnded { _ in endTabDrag() }, - including: renamingTabID == tab.id ? .subviews : .all - ) - } - } - } - .onScrollGeometryChange(for: StripScrollGeometry.self) { geo in - StripScrollGeometry( - contentOffsetX: geo.contentOffset.x, - containerWidth: geo.containerSize.width, - contentWidth: geo.contentSize.width - ) - } action: { _, new in - scrollGeometry = new - overflow = StripOverflow( - left: new.contentOffsetX > 0.5, - right: new.contentOffsetX + new.containerWidth < new.contentWidth - 0.5 - ) - } - // Keep the active tab visible: scrolls the minimum distance to - // reveal it beyond the fade rather than merely inside the viewport. - .onChange(of: project.selectedTabID) { _, id in - guard let id else { return } - // Preserve ScrollViewReader's reliable minimum reveal first, - // then refine it once SwiftUI has advanced the scroll layout. - performScroll(to: id, anchor: nil, using: proxy, animated: true) - DispatchQueue.main.async { - scrollToSelectedTab(using: proxy, animated: true) - } - } - // Selection is not the only thing that can hide the active tab. - // Keep it visible when the window/sidebar changes the viewport, - // tabs are inserted or reordered, or a live title/rename changes - // the width of content before it. - .onChange(of: maxStripWidth) { - scrollToSelectedTab(using: proxy) - } - .onChange(of: scrollGeometry.containerWidth) { - // Defer until the tab sizes have settled against the resized - // viewport before deciding whether the active tab needs help. - DispatchQueue.main.async { - scrollToSelectedTab(using: proxy) - } - } - .onChange(of: project.tabs.map(\.id)) { - scrollToSelectedTab(using: proxy) - } - .onChange(of: tabSizes) { - scrollToSelectedTab(using: proxy) - } - .onAppear { - // Restored sessions may open with an off-screen active tab. - DispatchQueue.main.async { - scrollToSelectedTab(using: proxy) - } - } - .mask { - HStack(spacing: 0) { - LinearGradient( - colors: [overflow.left ? .clear : .black, .black], - startPoint: .leading, endPoint: .trailing - ) - .frame(width: fadeWidth) - Color.black - LinearGradient( - colors: [.black, overflow.right ? .clear : .black], - startPoint: .leading, endPoint: .trailing - ) - .frame(width: fadeWidth) - } - } - .animation(.easeInOut(duration: 0.15), value: overflow) - .frame(maxWidth: maxStripWidth, alignment: .leading) - .fixedSize(horizontal: true, vertical: false) - } - - ChromeIconButton( - systemImage: "plus", - tooltip: "New Session (⌘T)", - font: .system(size: 10 * scale, weight: .semibold), - iconSize: 14 * scale, - tooltipAlignment: .leading - ) { - project.newSession() - } - } - .onPreferenceChange(TabFramePreferenceKey.self) { frames in - tabFrames = frames - let sizes = frames.mapValues(\.size) - if sizes != tabSizes { - tabSizes = sizes - } - } - } - - /// Moves only when the selected tab overlaps an active edge fade. The - /// custom anchor places that tab just beyond the fade instead of at the - /// viewport edge, where `scrollTo` would leave it partially obscured. - private func scrollToSelectedTab(using proxy: ScrollViewProxy, animated: Bool = false) { - guard let id = project.selectedTabID, - let selectedIndex = project.tabs.firstIndex(where: { $0.id == id }) else { return } - - guard scrollGeometry.containerWidth > 0, - let selectedSize = tabSizes[id] else { - performScroll(to: id, anchor: nil, using: proxy, animated: animated) - return - } - - var tabMinX = CGFloat(selectedIndex) * tabSpacing - for tab in project.tabs[.. safeMaxX + 0.5 { - anchor = UnitPoint(x: max(0, 1 - fadeWidth / availableSpace), y: 0.5) - } else { - return - } - - performScroll(to: id, anchor: anchor, using: proxy, animated: animated) - } - - private func performScroll( - to id: UUID, - anchor: UnitPoint?, - using proxy: ScrollViewProxy, - animated: Bool - ) { - let reveal = { - if let anchor { - proxy.scrollTo(id, anchor: anchor) - } else { - proxy.scrollTo(id) - } - } - if animated { - withAnimation(.easeInOut(duration: 0.2), reveal) - } else { - reveal() - } - } - - /// Reorders immediately as the pointer crosses another tab. This direct - /// gesture deliberately avoids a pasteboard drag session, which the - /// hidden title bar can otherwise claim as a window move first. - private func updateTabDrag(source: UUID, location: CGPoint) { - tabSplitDrag.update(sourceTabID: source, location: location, in: project) - NSCursor.closedHand.set() - guard let target = tabFrames.first(where: { - $0.key != source && $0.value.contains(location) - })?.key else { return } - withAnimation(.easeInOut(duration: 0.12)) { - project.moveTab(source, to: target) - } - } - - private func endTabDrag() { - tabSplitDrag.commit() - NSCursor.arrow.set() - } - - private func tabContextMenuItems(for tab: PaneTab) -> [AppKitContextMenuItem] { - var items: [AppKitContextMenuItem] = [ - .action(title: String(localized: tab.isPinned ? "Unpin Tab" : "Pin Tab")) { - project.setPinned(!tab.isPinned, for: tab) - }, - .separator, - .action(title: String(localized: "Rename…")) { renamingTabID = tab.id }, - ] - if tab.customName != nil { - items.append(.action(title: String(localized: "Use Automatic Title")) { - tab.customName = nil - }) - } - items.append(.separator) - items.append(.action(title: String(localized: "Set Color Marker…")) { - ProjectTabColorPanelController.shared.present(tab: tab) - }) - if tab.markerColor != nil { - items.append(.action(title: String(localized: "Remove Color Marker")) { - tab.markerColor = nil - }) - } - if case .file(let file) = tab.focusedContent { - items.append(.action(title: String(localized: "Reveal in Finder")) { - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: file.path)]) - }) - items.append(.action(title: String(localized: "Copy Absolute Path")) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(file.path, forType: .string) - }) - items.append(.separator) - } - if case .browser(let browser) = tab.focusedContent, !browser.urlString.isEmpty { - items.append(.action( - title: String(localized: "Open in Default Browser"), - enabled: browser.shareURL != nil - ) { browser.openInDefaultBrowser() }) - items.append(.action(title: String(localized: "Copy Address")) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(browser.urlString, forType: .string) - }) - items.append(.separator) - } - if let moveItem = moveTabMenuItem(for: tab) { - items.append(moveItem) - items.append(.separator) - } - items.append(.action(title: String(localized: "Close")) { project.close(tab) }) - items.append(.action( - title: String(localized: "Close Others"), - enabled: project.tabs.count > 1 - ) { project.closeOthers(tab) }) - items.append(.action( - title: String(localized: "Close Tabs to the Right"), - enabled: project.tabs.last?.id != tab.id - ) { project.closeToRight(of: tab) }) - items.append(.separator) - items.append(.action( - title: String(localized: "Close Files"), - enabled: project.hasFiles - ) { project.closeFiles() }) - items.append(.action( - title: String(localized: "Close Diffs"), - enabled: project.hasDiffs - ) { project.closeDiffs() }) - items.append(.separator) - items.append(.action(title: String(localized: "Close All")) { project.closeAll() }) - return items - } - - /// Builds the cross-project "Move Tab to Project" submenu from the live - /// destination list. Hidden entirely when no other project can host the - /// tab, so single-project windows keep a clean menu. - private func moveTabMenuItem(for tab: PaneTab) -> AppKitContextMenuItem? { - let destinations = manager.tabMoveDestinations(for: tab.id, in: project.id) - guard !destinations.isEmpty else { return nil } - - let targets: [AppKitContextMenuItem] = destinations.map { destination in - let title: String - if let windowTitle = destination.windowTitle, !windowTitle.isEmpty { - title = String( - localized: "\(destination.title) — \(windowTitle)", - comment: "Destination project and its window title in the Move Tab menu." - ) - } else { - title = destination.title - } - return .action(title: title, enabled: destination.isEnabled) { - moveTab(to: destination, tabID: tab.id, from: project.id) - } - } - return .submenu(title: String(localized: "Move Tab to Project"), items: targets) - } - - private func moveTab( - to destination: TerminalManager.TabMoveDestination, - tabID: UUID, - from sourceProjectID: UUID - ) { - let result = manager.moveTab( - id: tabID, - from: sourceProjectID, - to: destination.projectID, - in: destination.managerID - ) - guard let failure = result.failure else { return } - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "Couldn’t Move Tab") - alert.informativeText = failure.message - alert.addButton(withTitle: String(localized: "OK")) - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - alert.beginSheetModal(for: window) - } else { - alert.runModal() - } - } -} - -/// Collects each tab's global frame so a direct drag gesture can hit-test the -/// pointer even while the horizontal strip is moving under it. -private struct TabFramePreferenceKey: PreferenceKey { - static let defaultValue: [UUID: CGRect] = [:] - - static func reduce(value: inout [UUID: CGRect], nextValue: () -> [UUID: CGRect]) { - value.merge(nextValue()) { $1 } - } -} - -/// A tab in the strip. Shows the focused pane's title/icon, with a small -/// counter when the tab holds more than one pane. Observes the tab so focus -/// and layout changes refresh it; the focused content is observed by the -/// per-kind label below so its live title/dirty state shows. -private struct PaneTabItem: View { - @ObservedObject var tab: PaneTab - let isSelected: Bool - let select: () -> Void - let close: () -> Void - @Binding var renamingTabID: UUID? - - var body: some View { - let paneCount = tab.allPanes.count - if renamingTabID == tab.id { - TabRenameChrome( - systemImage: tab.focusedContent?.systemImage ?? "terminal", - browserIcon: focusedBrowser, - fileIconPath: focusedFileIconPath, - initialValue: tab.displayTitle ?? "", - commit: { name in - let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - tab.customName = trimmed.isEmpty ? nil : trimmed - }, - end: { renamingTabID = nil } - ) - } else { - // Double-click on any tab kind opens the inline rename field, - // mirroring the context menu's "Rename…" entry. - let startRename = { renamingTabID = tab.id } - switch tab.focusedContent { - case .session(let session): - SessionTabLabel(session: session, customTitle: tab.customName, markerColor: tab.markerColor, paneCount: paneCount, agentRollup: tab.agentRollup, isSelected: isSelected, select: select, close: close, onDoubleClick: startRename) - case .file(let file): - FileTabLabel(file: file, customTitle: tab.customName, markerColor: tab.markerColor, paneCount: paneCount, agentRollup: tab.agentRollup, isSelected: isSelected, select: select, close: close, onDoubleClick: startRename) - case .browser(let browser): - BrowserTabLabel(browser: browser, customTitle: tab.customName, markerColor: tab.markerColor, paneCount: paneCount, agentRollup: tab.agentRollup, isSelected: isSelected, select: select, close: close, onDoubleClick: startRename) - case .diff(let diff): - DiffTabLabel( - diff: diff, - customTitle: tab.customName, - markerColor: tab.markerColor, - paneCount: paneCount, - agentRollup: tab.agentRollup, - isSelected: isSelected, - select: select, - close: close, - onDoubleClick: startRename - ) - case nil: - EmptyView() - } - } - } - - private var focusedBrowser: BrowserTab? { - if case .browser(let browser) = tab.focusedContent { - browser - } else { - nil - } - } - - private var focusedFileIconPath: String? { - tab.focusedContent?.fileIconPath - } -} - -/// Inline editor shown in place of a tab while it's renamed — the same -/// affordance as the project row's rename. Commits on Return or focus loss, -/// cancels on Escape; an empty name returns the tab to its automatic title. -private struct TabRenameChrome: View { - @ObservedObject private var themeChanges = Theme.changes - let systemImage: String - let browserIcon: BrowserTab? - let fileIconPath: String? - let commit: (String) -> Void - let end: () -> Void - - @State private var draft: String - /// Set by the first commit/cancel so the focus-loss handler that fires - /// while the field is being torn down doesn't commit a second time. - @State private var finished = false - @FocusState private var focused: Bool - - init( - systemImage: String, - browserIcon: BrowserTab?, - fileIconPath: String?, - initialValue: String, - commit: @escaping (String) -> Void, - end: @escaping () -> Void - ) { - self.systemImage = systemImage - self.browserIcon = browserIcon - self.fileIconPath = fileIconPath - self.commit = commit - self.end = end - _draft = State(initialValue: initialValue) - } - - var body: some View { - HStack(spacing: 5) { - if let browserIcon { - BrowserFaviconView(browser: browserIcon, size: 11) - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(Color(nsColor: Theme.accent)) - } else if let fileIconPath { - MaterialFileIconView(path: fileIconPath, size: 12) - } else { - Image(systemName: systemImage) - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(Color(nsColor: Theme.accent)) - } - TextField("", text: $draft) - .textFieldStyle(.plain) - .font(.system(size: 11.5)) - .frame(width: 110) - .focused($focused) - .onSubmit { finish(apply: true) } - .onExitCommand { finish(apply: false) } - .onChange(of: focused) { - if !focused { finish(apply: true) } - } - } - .padding(.leading, 9) - .padding(.trailing, 5) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color.primary.opacity(0.09)) - ) - .onAppear { - DispatchQueue.main.async { focused = true } - } - } - - private func finish(apply: Bool) { - guard !finished else { return } - finished = true - if apply { commit(draft) } - end() - } -} - -private struct SessionTabLabel: View { - @ObservedObject var session: TerminalSession - /// User-assigned tab name overriding the live terminal title. - var customTitle: String? - let markerColor: ProjectTabMarkerColor? - let paneCount: Int - let agentRollup: ZshellAgentRollup? - let isSelected: Bool - let select: () -> Void - let close: () -> Void - var onDoubleClick: (() -> Void)? = nil - - var body: some View { - TabItemChrome( - systemImage: "terminal", - title: customTitle ?? session.title, - markerColor: markerColor, - paneCount: paneCount, - agentRollup: agentRollup, - isSelected: isSelected, - select: select, - close: close, - onDoubleClick: onDoubleClick - ) - } -} - -private struct FileTabLabel: View { - @ObservedObject var file: FileTab - /// User-assigned tab name overriding the file name. - var customTitle: String? - let markerColor: ProjectTabMarkerColor? - let paneCount: Int - let agentRollup: ZshellAgentRollup? - let isSelected: Bool - let select: () -> Void - let close: () -> Void - var onDoubleClick: (() -> Void)? = nil - - var body: some View { - TabItemChrome( - systemImage: "doc.text", - fileIconPath: file.path, - title: customTitle ?? file.name, - markerColor: markerColor, - paneCount: paneCount, - agentRollup: agentRollup, - isSelected: isSelected, - isDirty: file.isDirty, - select: select, - close: close, - onDoubleClick: onDoubleClick - ) - .help(file.path) - } -} - -private struct BrowserTabLabel: View { - @ObservedObject var browser: BrowserTab - /// User-assigned tab name overriding the webpage title. - var customTitle: String? - let markerColor: ProjectTabMarkerColor? - let paneCount: Int - let agentRollup: ZshellAgentRollup? - let isSelected: Bool - let select: () -> Void - let close: () -> Void - var onDoubleClick: (() -> Void)? = nil - - var body: some View { - TabItemChrome( - systemImage: "globe", - browserIcon: browser, - title: customTitle ?? browser.title, - markerColor: markerColor, - paneCount: paneCount, - agentRollup: agentRollup, - isSelected: isSelected, - select: select, - close: close, - onDoubleClick: onDoubleClick - ) - .help(browser.urlString) - } -} - -private struct DiffTabLabel: View { - @ObservedObject var diff: DiffTab - var customTitle: String? - let markerColor: ProjectTabMarkerColor? - let paneCount: Int - let agentRollup: ZshellAgentRollup? - let isSelected: Bool - let select: () -> Void - let close: () -> Void - var onDoubleClick: (() -> Void)? = nil - - var body: some View { - TabItemChrome( - systemImage: "plus.forwardslash.minus", - fileIconPath: diff.path, - title: customTitle ?? diff.title, - markerColor: markerColor, - paneCount: paneCount, - agentRollup: agentRollup, - isSelected: isSelected, - isDirty: diff.isDirty, - select: select, - close: close, - onDoubleClick: onDoubleClick - ) - .help(diff.path) - } -} - -private struct TabItemChrome: View { - @ObservedObject private var settings = AppSettings.shared - @ObservedObject private var themeChanges = Theme.changes - let systemImage: String - var browserIcon: BrowserTab? = nil - var fileIconPath: String? = nil - let title: String - var markerColor: ProjectTabMarkerColor? = nil - var paneCount: Int = 1 - var agentRollup: ZshellAgentRollup? = nil - let isSelected: Bool - var isDirty = false - let select: () -> Void - let close: () -> Void - var onDoubleClick: (() -> Void)? = nil - - @State private var isHovering = false - - private var scale: CGFloat { CGFloat(settings.interfaceScale) } - - var body: some View { - Button(action: select) { - HStack(spacing: 5) { - if let markerColor { - Image(systemName: "tag.fill") - .font(.system(size: 7.5, weight: .semibold)) - .foregroundStyle(Color(nsColor: markerColor.nsColor)) - .accessibilityHidden(true) - } - if let browserIcon { - BrowserFaviconView(browser: browserIcon, size: 11 * scale) - .font(.system(size: 9 * scale, weight: .medium)) - .foregroundStyle( - isSelected - ? AnyShapeStyle(Color(nsColor: Theme.accent)) - : AnyShapeStyle(.tertiary) - ) - .opacity(isSelected ? 1 : 0.78) - } else if let fileIconPath { - MaterialFileIconView( - path: fileIconPath, - size: 12 * scale, - opacity: isSelected ? 1 : 0.82 - ) - } else { - Image(systemName: systemImage) - .font(.system(size: 9 * scale, weight: .medium)) - .foregroundStyle( - isSelected - ? AnyShapeStyle(Color(nsColor: Theme.accent)) - : AnyShapeStyle(.tertiary) - ) - } - Text(verbatim: title) - .font(.system(size: 11.5 * scale)) - .foregroundStyle(isSelected ? .primary : .secondary) - .lineLimit(1) - if paneCount > 1 { - HStack(spacing: 2) { - Image(systemName: "square.split.2x1") - .font(.system(size: 7.5 * scale, weight: .semibold)) - Text(verbatim: "\(paneCount)") - .font(.system(size: 9 * scale, weight: .semibold)) - } - .foregroundStyle(.tertiary) - } - if let agentRollup { - AgentStatusBadgeRepresentable(rollup: agentRollup) - .fixedSize() - } - if isHovering { - Button(action: close) { - Image(systemName: "xmark") - .font(.system(size: 8 * scale, weight: .bold)) - .foregroundStyle(.secondary) - .frame(width: 14 * scale, height: 14 * scale) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } else if isDirty { - Circle() - .fill(.secondary) - .frame(width: 5 * scale, height: 5 * scale) - .frame(width: 14 * scale, height: 14 * scale) - } else { - Spacer() - .frame(width: 14 * scale) - } - } - .padding(.leading, 9) - .padding(.trailing, 5) - .padding(.vertical, 4) - .contentShape(RoundedRectangle(cornerRadius: 6)) - } - .buttonStyle(.plain) - // Attached to the button itself: a button consumes clicks before - // gestures on enclosing views see them, so a double-tap on an - // ancestor would never fire. The single click still selects — the - // rename just piggybacks on the second click, like Safari tabs. - .onTapGesture(count: 2) { onDoubleClick?() } - // Cap tab width so a long title truncates instead of stretching the - // tab; short titles still shrink to fit (maxWidth is an upper bound). - .frame(maxWidth: 220) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(isSelected ? Color.primary.opacity(0.09) : (isHovering ? Color.primary.opacity(0.04) : .clear)) - ) - .overlay { MiddleClickCatcher(action: close) } - .onHover { isHovering = $0 } - .accessibilityValue(markerAccessibilityValue) - } - - private var markerAccessibilityValue: String { - guard let markerColor else { return String(localized: "No color marker") } - return String( - localized: "Color marker \(markerColor.displayValue)", - comment: "Accessibility value for a project or tab color marker. The placeholder is an sRGB hex color." - ) - } -} diff --git a/mac/zshell/FileContentSearchModel.swift b/mac/zshell/FileContentSearchModel.swift index 4946269..b90a590 100644 --- a/mac/zshell/FileContentSearchModel.swift +++ b/mac/zshell/FileContentSearchModel.swift @@ -136,6 +136,7 @@ final class FileContentSearchModel: ObservableObject { // in the fresh state nor surface as a failure. runID += 1 stopProcess() + buffer = nil isRunning = false matches = [] isTruncated = false @@ -165,7 +166,7 @@ final class FileContentSearchModel: ObservableObject { process.executableURL = URL(fileURLWithPath: "/usr/bin/grep") // -F keeps the query literal; -I skips binary files, which the // editor could not show a hit in anyway. - var arguments = ["-r", "-n", "-I", "-F"] + var arguments = ["-r", "-n", "-I", "-F", "-H", "--null"] if !isCaseSensitive { arguments.append("-i") } for directory in Self.excludedDirectories { arguments.append("--exclude-dir=\(directory)") @@ -178,6 +179,19 @@ final class FileContentSearchModel: ObservableObject { process.standardOutput = stdout let stderr = Pipe() process.standardError = stderr + let readers = DispatchGroup() + readers.enter() + readers.enter() + process.terminationHandler = { [weak self] process in + let terminationStatus = process.terminationStatus + // Process exit can arrive before the pipes deliver their last chunk. + readers.notify(queue: .main) { [weak self] in + guard let model = self else { return } + assumeMainActor { + model.processDidTerminate(exitStatus: terminationStatus, ofRun: runID) + } + } + } do { try process.run() @@ -189,39 +203,30 @@ final class FileContentSearchModel: ObservableObject { } self.process = process - stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in - let chunk = handle.availableData - if chunk.isEmpty { - handle.readabilityHandler = nil - return - } - buffer.ingest(chunk) - self?.requestFlush() - if buffer.hasReachedLimit() { - self?.stopAtLimitFromHandler() + let stdoutReader = Thread { [weak self] in + defer { readers.leave() } + let handle = stdout.fileHandleForReading + while let chunk = try? handle.read(upToCount: 16_384), !chunk.isEmpty { + buffer.ingest(chunk) + self?.requestFlush(ofRun: runID) + if buffer.hasReachedLimit() { + self?.stopAtLimitFromHandler(ofRun: runID) + } } } + stdoutReader.qualityOfService = .userInitiated + stdoutReader.start() // Drained so a chatty grep (unreadable paths, and so on) cannot fill // the pipe and stall; kept only to explain a failed search. - stderr.fileHandleForReading.readabilityHandler = { handle in - let chunk = handle.availableData - if chunk.isEmpty { - handle.readabilityHandler = nil - return - } - buffer.appendError(chunk) - } - - process.terminationHandler = { [weak self] process in - let terminationStatus = process.terminationStatus - DispatchQueue.main.async { [weak self] in - assumeMainActor { - self?.processDidTerminate( - exitStatus: terminationStatus, ofRun: runID - ) - } + let stderrReader = Thread { + defer { readers.leave() } + let handle = stderr.fileHandleForReading + while let chunk = try? handle.read(upToCount: 8_192), !chunk.isEmpty { + buffer.appendError(chunk) } } + stderrReader.qualityOfService = .utility + stderrReader.start() } /// Terminates the running grep, if any. Its termination handler still @@ -229,9 +234,6 @@ final class FileContentSearchModel: ObservableObject { private func stopProcess() { guard let process else { return } self.process = nil - // The stdout handler holds its own buffer reference, so a few chunks - // still in flight land in an orphaned buffer and are dropped with it. - buffer = nil if process.isRunning { process.terminate() } @@ -251,15 +253,19 @@ final class FileContentSearchModel: ObservableObject { failureMessage = detail.map(String.init) ?? String(localized: "Search failed") } + buffer = nil } // MARK: - Throttled flush /// Called from grep's stdout handler; coalesces into ~200 ms main-actor /// batches so a fast stream never floods the UI. - private nonisolated func requestFlush() { + private nonisolated func requestFlush(ofRun runID: Int) { DispatchQueue.main.async { - assumeMainActor { self.scheduleFlush() } + assumeMainActor { + guard runID == self.runID else { return } + self.scheduleFlush() + } } } @@ -300,9 +306,12 @@ final class FileContentSearchModel: ObservableObject { } /// Called from grep's stdout handler once the buffer reports the cap. - private nonisolated func stopAtLimitFromHandler() { + private nonisolated func stopAtLimitFromHandler(ofRun runID: Int) { DispatchQueue.main.async { - assumeMainActor { self.finishAtLimit() } + assumeMainActor { + guard runID == self.runID else { return } + self.finishAtLimit() + } } } @@ -345,9 +354,10 @@ final class FileContentSearchModel: ObservableObject { /// Accumulates grep's output between throttled UI flushes. All state is /// lock-guarded: it is written from grep's pipe handler queues and read on /// the main actor. -private nonisolated final class MatchBuffer { +private nonisolated final class MatchBuffer: @unchecked Sendable { private let lock = NSLock() private var fragment: [UInt8] = [] + private var hasPathSeparator = false private var pending: [FileContentSearchModel.Match] = [] private var deliveredCount = 0 private var nextID = 0 @@ -384,14 +394,16 @@ private nonisolated final class MatchBuffer { lock.lock() defer { lock.unlock() } for byte in chunk { - if byte == 0x0A { + if byte == 0x0A, hasPathSeparator { appendLine(fragment) fragment.removeAll(keepingCapacity: true) + hasPathSeparator = false } else if fragment.count < Self.maxLineBytes { // The path and line number sit at the front of the line, so // dropping the tail of an oversized line still yields a // usable (byte-capped) content preview. fragment.append(byte) + if byte == 0 { hasPathSeparator = true } } } if !reachedLimit, deliveredCount + pending.count >= maxMatches { @@ -406,6 +418,7 @@ private nonisolated final class MatchBuffer { guard !fragment.isEmpty else { return } appendLine(fragment) fragment.removeAll(keepingCapacity: true) + hasPathSeparator = false } /// Moves up to `max` buffered matches out, in order. Called on the main @@ -440,21 +453,18 @@ private nonisolated final class MatchBuffer { .trimmingCharacters(in: .whitespacesAndNewlines) } - /// Parses one `path:line:content` line as BSD grep prints it for - /// `grep -r -n`. The path field may itself contain colons, so the split - /// at the first two colons only counts when the second field is a plain - /// number; anything else is skipped rather than shown garbled. + /// `--null` keeps colons and newlines in file names distinct from the + /// line number and content delimiters. private func appendLine(_ bytes: [UInt8]) { guard !reachedLimit else { return } - let line = String(decoding: bytes, as: UTF8.self) - guard let firstColon = line.firstIndex(of: ":") else { return } - let afterFirst = line.index(after: firstColon) - guard let secondColon = line[afterFirst...].firstIndex(of: ":") else { return } - guard let lineNumber = Int(line[afterFirst.. maxContentLength { content = String(content.prefix(maxContentLength)) } diff --git a/mac/zshell/FileViewerView.swift b/mac/zshell/FileViewerView.swift index eb29169..1656afa 100644 --- a/mac/zshell/FileViewerView.swift +++ b/mac/zshell/FileViewerView.swift @@ -672,7 +672,7 @@ private final class FileSaveErrorBar: NSView { super.init(frame: frameRect) wantsLayer = true isHidden = true - layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.04).cgColor + updateAppearanceColors() let icon = NSImageView() icon.image = NSImage( @@ -703,4 +703,20 @@ private final class FileSaveErrorBar: NSView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateAppearanceColors() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateAppearanceColors() + } + + private func updateAppearanceColors() { + effectiveAppearance.performAsCurrentDrawingAppearance { + layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.04).cgColor + } + } } diff --git a/mac/zshell/GlobalTerminalOverlay.swift b/mac/zshell/GlobalTerminalOverlay.swift index 2751eb0..6d608fb 100644 --- a/mac/zshell/GlobalTerminalOverlay.swift +++ b/mac/zshell/GlobalTerminalOverlay.swift @@ -43,6 +43,10 @@ struct QuickTerminalShortcut: Equatable { var persistedValue: String { "\(keyCode):\(modifiers)" } + static func keyCode(for character: Character) -> UInt32? { + keyLabels.first { $0.value == character.uppercased() }?.key + } + var displayString: String { var result = "" if modifiers & UInt32(controlKey) != 0 { result += "⌃" } @@ -78,6 +82,12 @@ struct QuickTerminalShortcut: Equatable { UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5", UInt32(kVK_ANSI_6): "6", UInt32(kVK_ANSI_7): "7", UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9", + UInt32(kVK_ANSI_LeftBracket): "[", UInt32(kVK_ANSI_RightBracket): "]", + UInt32(kVK_ANSI_Minus): "-", UInt32(kVK_ANSI_Equal): "=", + UInt32(kVK_ANSI_Semicolon): ";", UInt32(kVK_ANSI_Quote): "'", + UInt32(kVK_ANSI_Comma): ",", UInt32(kVK_ANSI_Period): ".", + UInt32(kVK_ANSI_Slash): "/", UInt32(kVK_ANSI_Backslash): "\\", + UInt32(kVK_ANSI_Grave): "`", UInt32(kVK_Space): "Space", UInt32(kVK_Return): "Return", UInt32(kVK_Tab): "Tab", UInt32(kVK_Delete): "Delete", UInt32(kVK_LeftArrow): "←", UInt32(kVK_RightArrow): "→", @@ -618,7 +628,7 @@ private final class GlobalTerminalContentView: NSView { layer?.cornerCurve = .continuous layer?.masksToBounds = true layer?.borderWidth = 1 - layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.25).cgColor + updateAppearanceColors() materialBackground.material = .hudWindow materialBackground.blendingMode = .behindWindow @@ -658,6 +668,22 @@ private final class GlobalTerminalContentView: NSView { fatalError("init(coder:) has not been implemented") } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateAppearanceColors() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateAppearanceColors() + } + + private func updateAppearanceColors() { + effectiveAppearance.performAsCurrentDrawingAppearance { + layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.25).cgColor + } + } + func setPinState(_ pinned: Bool) { titlebar.setPinState(pinned) } diff --git a/mac/zshell/Localizable.xcstrings b/mac/zshell/Localizable.xcstrings index af0bdbd..8b2708d 100644 --- a/mac/zshell/Localizable.xcstrings +++ b/mac/zshell/Localizable.xcstrings @@ -2059,6 +2059,22 @@ } } }, + "Collapse Group": { + "localizations": { + "ja": { + "stringUnit": { + "state": "translated", + "value": "グループを折りたたむ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "折叠分组" + } + } + } + }, "Collapsed": { "localizations": { "ja": { @@ -3677,6 +3693,22 @@ } } }, + "Expand Group": { + "localizations": { + "ja": { + "stringUnit": { + "state": "translated", + "value": "グループを展開" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "展开分组" + } + } + } + }, "Expanded": { "localizations": { "ja": { @@ -5496,6 +5528,22 @@ } } }, + "Move this tab to a project with the same local or SSH location, or create a new project from it.": { + "localizations": { + "ja": { + "stringUnit": { + "state": "translated", + "value": "このタブを同じローカルまたは SSH 接続先のプロジェクトに移動するか、このタブから新しいプロジェクトを作成してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请将此标签页移到相同本地或 SSH 位置的项目,或从此标签页新建项目。" + } + } + } + }, "Move to Group": { "localizations": { "zh-Hans": { @@ -5914,6 +5962,22 @@ } } }, + "New Session in Group": { + "localizations": { + "ja": { + "stringUnit": { + "state": "translated", + "value": "グループ内に新しいセッションを作成" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在分组中新建会话" + } + } + } + }, "New Tab": { "localizations": { "ja": { @@ -5930,6 +5994,38 @@ } } }, + "New Tab Group": { + "localizations": { + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいタブグループ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建标签页分组" + } + } + } + }, + "New Ungrouped Project": { + "localizations": { + "ja": { + "stringUnit": { + "state": "translated", + "value": "未分類のプロジェクトを作成" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建未分组项目" + } + } + } + }, "New Window": { "localizations": { "ja": { @@ -8645,6 +8741,38 @@ } } }, + "Scroll Tabs Left": { + "localizations": { + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを左にスクロール" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向左滚动标签页" + } + } + } + }, + "Scroll Tabs Right": { + "localizations": { + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを右にスクロール" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右滚动标签页" + } + } + } + }, "Search File Contents": { "localizations": { "ja": { diff --git a/mac/zshell/Panes.swift b/mac/zshell/Panes.swift index adcde15..2f55c7b 100644 --- a/mac/zshell/Panes.swift +++ b/mac/zshell/Panes.swift @@ -391,6 +391,7 @@ final class PaneTab: nonisolated ObservableObject, nonisolated Identifiable { } } @Published var isPinned: Bool + @Published var tabGroupID: UUID? @Published var markerColor: ProjectTabMarkerColor? /// Overrides applied only when this tab creates a later terminal. Existing /// terminal panes keep the process environment they were launched with. diff --git a/mac/zshell/PinContextMenuMonitor.swift b/mac/zshell/PinContextMenuMonitor.swift index 8d2f786..6f9199f 100644 --- a/mac/zshell/PinContextMenuMonitor.swift +++ b/mac/zshell/PinContextMenuMonitor.swift @@ -63,17 +63,27 @@ final class AppKitContextMenuMonitorView: NSView { self.visibleRect.contains(self.convert(event.locationInWindow, from: nil)) else { return input } - self.activeHandlers = [:] - self.nextHandlerTag = 0 - let menu = self.makeMenu(items: self.items) - _ = menu.popUp(positioning: nil, at: self.convert(event.locationInWindow, from: nil), in: self) - self.activeHandlers = [:] + self.popUp( + items: self.items, + at: self.convert(event.locationInWindow, from: nil), + in: self + ) return AppKitContextMenuEvent(nil) } return output.value } } + /// Native callers share the menu registry without installing a monitor + /// on every row; an unattached presenter owns handlers only while open. + func popUp(items: [AppKitContextMenuItem], at point: NSPoint, in view: NSView) { + activeHandlers = [:] + nextHandlerTag = 0 + let menu = makeMenu(items: items) + _ = menu.popUp(positioning: nil, at: point, in: view) + activeHandlers = [:] + } + private func makeMenu(items: [AppKitContextMenuItem]) -> NSMenu { let menu = NSMenu() menu.autoenablesItems = false diff --git a/mac/zshell/Project.swift b/mac/zshell/Project.swift index 27c01e4..60f037b 100644 --- a/mac/zshell/Project.swift +++ b/mac/zshell/Project.swift @@ -24,10 +24,9 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { @Published var isPinned: Bool @Published var markerColor: ProjectTabMarkerColor? /// User-pinned project directory ("Set Project Directory…" on the - /// project row). When set, the file tree and git panels always anchor - /// here. Nil means automatic: the closest git repository containing the - /// selected session's working directory, re-derived as the session - /// moves (see `panelRoot(followingSessionAt:)`). + /// project row). While it exists, the file tree and git panels anchor + /// here. Nil means automatic, following the terminal's foreground + /// repository and working directory (see `panelRoot(followingSessionAt:)`). @Published var customDirectory: String? /// The sidebar group this project sits under, nil when ungrouped. The /// group's kind decides where new terminals of the project start: a @@ -38,8 +37,11 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { /// Existing PTYs intentionally keep the environment they started with. @Published var launchSettings = TerminalLaunchSettings() @Published var tabs: [PaneTab] = [] + @Published private(set) var tabGroups: [SessionTabGroup] = [] + private var isRestoringTabs = false @Published var selectedTabID: UUID? { didSet { + if !isRestoringTabs { revealSelectedTabGroup() } guard selectedTabID != oldValue, let selectedTabID else { return } recentTabIDs.removeAll { $0 == selectedTabID } recentTabIDs.insert(selectedTabID, at: 0) @@ -290,10 +292,10 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { // MARK: - Sessions - /// When no directory is given, the new session starts in the pinned - /// project directory, then the current session's working directory - /// (home when neither is known). A manual project directory is an - /// explicit choice, so it also becomes the default for future terminals. + /// When no directory is given, a local session starts in the pinned + /// project directory, then the group's default, then the current + /// session's working directory (home when none is known). A manual + /// project directory is an explicit choice for future terminals. @discardableResult func newSession( directory: String? = nil, @@ -765,7 +767,8 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { projectID: id, customTitle: tabs.first { $0.paneID(forContent: session.id) != nil }?.customName, workingDirectory: session.currentDirectoryPath, - closedAt: Date() + closedAt: Date(), + tabGroupID: tabs.first { $0.paneID(forContent: session.id) != nil }?.tabGroupID ) } @@ -839,6 +842,121 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { } } + // MARK: - Tab groups + + func tabGroup(id: UUID?) -> SessionTabGroup? { + guard let id else { return nil } + return tabGroups.first { $0.id == id } + } + + var visibleTabs: [PaneTab] { + tabs.filter { tabGroup(id: $0.tabGroupID)?.isCollapsed != true } + } + + @discardableResult + func createTabGroup(containing tab: PaneTab? = nil) -> SessionTabGroup { + let group = SessionTabGroup(name: String(localized: "New Tab Group")) + tabGroups.append(group) + if let tab { moveTab(tab.id, toGroup: group.id) } + return group + } + + func renameTabGroup(_ id: UUID, to name: String) { + guard let name = Self.normalizedCustomName(name), + let index = tabGroups.firstIndex(where: { $0.id == id }) else { return } + tabGroups[index].name = name + } + + func setTabGroupCollapsed(_ collapsed: Bool, id: UUID) { + guard let index = tabGroups.firstIndex(where: { $0.id == id }), + tabGroups[index].isCollapsed != collapsed else { return } + tabGroups[index].isCollapsed = collapsed + } + + func removeTabGroup(_ id: UUID) { + for tab in tabs where tab.tabGroupID == id { tab.tabGroupID = nil } + tabGroups.removeAll { $0.id == id } + normalizeTabOrder() + } + + func moveTabGroup(_ id: UUID, to targetID: UUID) { + guard id != targetID, + let source = tabGroups.firstIndex(where: { $0.id == id }), + let target = tabGroups.firstIndex(where: { $0.id == targetID }) else { return } + let group = tabGroups.remove(at: source) + tabGroups.insert(group, at: target) + normalizeTabOrder() + } + + func moveTab(_ id: UUID, toGroup groupID: UUID?) { + guard let index = tabs.firstIndex(where: { $0.id == id }), + groupID == nil || tabGroup(id: groupID) != nil else { return } + let tab = tabs[index] + if tab.tabGroupID != groupID || (groupID != nil && tab.isPinned) { + tabs.remove(at: index) + if groupID != nil { tab.isPinned = false } + tab.tabGroupID = groupID + // A group-header drop appends after its existing members. Using + // the source's old global position would unexpectedly insert it + // before tabs already organized in the destination. + tabs.append(tab) + normalizeTabOrder() + } + if let groupID { setTabGroupCollapsed(false, id: groupID) } + } + + @discardableResult + func newSession(inTabGroup groupID: UUID) -> TerminalSession? { + guard tabGroup(id: groupID) != nil else { return nil } + let session = newSession() + if let tab = selectedTab { moveTab(tab.id, toGroup: groupID) } + return session + } + + func beginRestoringTabGroups(_ groups: [SessionTabGroup]) { + isRestoringTabs = true + var seen = Set() + tabGroups = groups.compactMap { group in + guard seen.insert(group.id).inserted else { return nil } + var group = group + group.name = Self.normalizedCustomName(group.name) + ?? String(localized: "New Tab Group") + return group + } + } + + func finishRestoringTabGroups() { + // The saved active tab can belong to a group the user left collapsed. + normalizeTabOrder() + isRestoringTabs = false + } + + private func revealSelectedTabGroup() { + guard let groupID = selectedTab?.tabGroupID else { return } + setTabGroupCollapsed(false, id: groupID) + } + + /// Keep each group contiguous so visual order, keyboard navigation and + /// "Close Tabs to the Right" all describe the same sequence. Fixed tabs + /// occupy their own section and leave a group when pinned. + private func normalizeTabOrder() { + let positions = Dictionary(uniqueKeysWithValues: tabGroups.enumerated().map { + ($0.element.id, $0.offset) + }) + for tab in tabs where tab.isPinned || tab.tabGroupID.map({ positions[$0] == nil }) == true { + if tab.tabGroupID != nil { tab.tabGroupID = nil } + } + func section(_ tab: PaneTab) -> Int { + if tab.isPinned { return -2 } + return tab.tabGroupID.flatMap { positions[$0] } ?? -1 + } + let ordered = tabs.enumerated().sorted { + let first = section($0.element), second = section($1.element) + return first == second ? $0.offset < $1.offset : first < second + }.map(\.element) + if ordered.map(\.id) != tabs.map(\.id) { tabs = ordered } + } + // MARK: - Tab selection func setPinned(_ pinned: Bool, for tab: PaneTab) { @@ -848,8 +966,10 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { tabs.remove(at: index) tab.isPinned = pinned + if pinned { tab.tabGroupID = nil } let destination = tabs.firstIndex(where: { !$0.isPinned }) ?? tabs.endIndex tabs.insert(tab, at: destination) + normalizeTabOrder() } /// Reorders a tab within its pinned or unpinned section. Cross-project @@ -864,8 +984,11 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { var reorderedTabs = tabs let draggedTab = reorderedTabs.remove(at: draggedIndex) + draggedTab.tabGroupID = tabs[targetIndex].tabGroupID reorderedTabs.insert(draggedTab, at: targetIndex) tabs = reorderedTabs + normalizeTabOrder() + revealSelectedTabGroup() } /// Moves a tab into another tab's pane tree at the indicated drop edge. @@ -914,6 +1037,7 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { func detachTabForTransfer(id: UUID) -> PaneTab? { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return nil } let tab = tabs[index] + tab.tabGroupID = nil unregisterTransferOwnership(of: tab) recentTabIDs.removeAll { $0 == id } tabs.remove(at: index) @@ -934,8 +1058,10 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { } tab.sessions.forEach { $0.transferHost(to: manager) } tab.browsers.forEach { $0.transferHost(to: manager) } + tab.tabGroupID = nil registerTransferOwnership(of: tab) tabs.append(tab) + normalizeTabOrder() selectedTabID = tab.id } @@ -1035,6 +1161,7 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { isPinned: snap.isPinned ) tab.customName = snap.customName + tab.tabGroupID = tabGroup(id: snap.tabGroupID)?.id tab.markerColor = snap.markerColorHex.flatMap(ProjectTabMarkerColor.init(hex:)) tab.launchSettingsOverride = snap.launchSettingsOverride append(tab) @@ -1117,6 +1244,7 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable { if let selectedTabID, let index = tabs.firstIndex(where: { $0.id == selectedTabID }), !tabs[index].isPinned { + tab.tabGroupID = tabs[index].tabGroupID tabs.insert(tab, at: index + 1) } else { let destination = tabs.firstIndex(where: { !$0.isPinned }) ?? tabs.endIndex diff --git a/mac/zshell/ProjectFileSearch.swift b/mac/zshell/ProjectFileSearch.swift index dbf1fbd..629077b 100644 --- a/mac/zshell/ProjectFileSearch.swift +++ b/mac/zshell/ProjectFileSearch.swift @@ -201,13 +201,15 @@ nonisolated enum ProjectFileSearch { enumerator.skipDescendants() continue } + // Enumeration can expand /var to /private/var; compare the same path form. + let absolutePath = url.standardizedFileURL.path guard let values = try? url.resourceValues(forKeys: keySet), values.isDirectory != true, values.isRegularFile == true, - url.path.hasPrefix(rootPrefix) + absolutePath.hasPrefix(rootPrefix) else { continue } - let relativePath = String(url.path.dropFirst(rootPrefix.count)) - files.append(makeFile(relativePath: relativePath, absolutePath: url.path, root: root)) + let relativePath = String(absolutePath.dropFirst(rootPrefix.count)) + files.append(makeFile(relativePath: relativePath, absolutePath: absolutePath, root: root)) } return files.sorted(by: fileOrder) } diff --git a/mac/zshell/ProjectGroup.swift b/mac/zshell/ProjectGroup.swift index 69b16df..ea43477 100644 --- a/mac/zshell/ProjectGroup.swift +++ b/mac/zshell/ProjectGroup.swift @@ -99,6 +99,15 @@ final class ProjectGroupStore: ObservableObject { save() } + func move(_ groupID: UUID, to targetID: UUID) { + guard groupID != targetID, + let source = groups.firstIndex(where: { $0.id == groupID }), + let target = groups.firstIndex(where: { $0.id == targetID }) else { return } + let group = groups.remove(at: source) + groups.insert(group, at: target) + save() + } + func remove(_ group: ProjectGroup) { groups.removeAll { $0.id == group.id } save() @@ -130,3 +139,17 @@ final class ProjectGroupStore: ObservableObject { } } } + +/// Tab groups belong to one project; their identifiers must never follow a +/// transferred tab into another project's independent group namespace. +struct SessionTabGroup: Identifiable, Codable, Equatable { + let id: UUID + var name: String + var isCollapsed: Bool + + init(id: UUID = UUID(), name: String, isCollapsed: Bool = false) { + self.id = id + self.name = name + self.isCollapsed = isCollapsed + } +} diff --git a/mac/zshell/QuickLaunchEditorController.swift b/mac/zshell/QuickLaunchEditorController.swift index 77b653d..eb24454 100644 --- a/mac/zshell/QuickLaunchEditorController.swift +++ b/mac/zshell/QuickLaunchEditorController.swift @@ -35,9 +35,6 @@ final class QuickLaunchEditorController: NSObject, NSWindowDelegate { private let errorLabel = NSTextField(wrappingLabelWithString: "") private let saveButton = NSButton(title: "", target: nil, action: nil) private var grid: NSGridView? - /// The fitting size changes when the type popup switches forms; the panel - /// remembers the top edge so the window grows downward, like a sheet. - private var anchoredTop: CGFloat? private init(entry: QuickLaunchEntry?) { editingEntry = entry @@ -266,6 +263,7 @@ final class QuickLaunchEditorController: NSObject, NSWindowDelegate { stack.bottomAnchor.constraint( equalTo: window.contentView!.bottomAnchor, constant: -16 ), + stack.widthAnchor.constraint(equalToConstant: 440), grid.widthAnchor.constraint(equalTo: stack.widthAnchor), kindDescription.widthAnchor.constraint(equalTo: stack.widthAnchor), buttonRow.widthAnchor.constraint(equalTo: stack.widthAnchor), @@ -306,6 +304,7 @@ final class QuickLaunchEditorController: NSObject, NSWindowDelegate { @objc private func kindChanged() { guard let grid else { return } + errorLabel.isHidden = true let hideCommand = selectedKind != .command let hideSSH = selectedKind != .ssh for row in 3.. 0, size.height > 0 else { return } - let oldTop = anchoredTop ?? window.frame.maxY - anchoredTop = oldTop + let oldFrame = window.frame window.setContentSize(size) // setContentSize keeps the bottom-left corner fixed; re-anchor the // top edge and re-center horizontally so the form grows downward. window.setFrameOrigin(NSPoint( - x: window.frame.midX - size.width / 2, - y: oldTop - window.frame.height + x: oldFrame.midX - window.frame.width / 2, + y: oldFrame.maxY - window.frame.height )) } @@ -448,10 +446,11 @@ final class QuickLaunchEditorController: NSObject, NSWindowDelegate { port = value } let options = optionsField.stringValue.trimmingCharacters(in: .whitespaces) + let endpoint = try SSHEndpoint(host: host, user: user, port: port) return .ssh( - user: user.isEmpty ? nil : user, - host: host, - port: port, + user: endpoint.user, + host: endpoint.host, + port: endpoint.port, extraArguments: options.isEmpty ? nil : options ) } diff --git a/mac/zshell/QuickLaunchPanelController.swift b/mac/zshell/QuickLaunchPanelController.swift index ef445b1..6a09cc2 100644 --- a/mac/zshell/QuickLaunchPanelController.swift +++ b/mac/zshell/QuickLaunchPanelController.swift @@ -63,6 +63,7 @@ final class QuickLaunchPanelController: NSObject { private let searchField = NSTextField() private let clearButton = NSButton() + private var selectionButtons: [NSButton] = [] // A table subclass: without it the table claims every mouse-down // (including ones on the rows' buttons), so the row's edit/delete // buttons could never receive a click. @@ -87,7 +88,7 @@ final class QuickLaunchPanelController: NSObject { private var query = "" { didSet { guard query != oldValue else { return } - refilterAndReload() + refilterAndReload(preservingSelection: false) } } @@ -146,7 +147,8 @@ final class QuickLaunchPanelController: NSObject { searchField.stringValue = "" query = "" - refilterAndReload() + updateClearButton() + refilterAndReload(preservingSelection: false) applyTheme() position(panel: panel) panel.makeKeyAndOrderFront(nil) @@ -296,6 +298,7 @@ final class QuickLaunchPanelController: NSObject { .init(pointSize: 11, weight: .medium) ) closeButton.isBordered = false + closeButton.keyEquivalent = "\u{1b}" closeButton.contentTintColor = .secondaryLabelColor closeButton.setAccessibilityLabel(String( localized: "Close", @@ -358,10 +361,8 @@ final class QuickLaunchPanelController: NSObject { tableView.dataSource = self tableView.delegate = self tableView.target = self - // One click launches — the launcher's "one-click invoke". Row-trailing - // edit/delete buttons take their own clicks; double-clicking a row - // would fire the action again after the panel closed, so there is - // deliberately no doubleAction. + // Row buttons handle their own clicks; the launch action ignores + // events arriving after the panel closes, including a second click. tableView.action = #selector(tableViewClicked) tableView.setAccessibilityLabel(String( localized: "Quick Launch entries", @@ -441,6 +442,12 @@ final class QuickLaunchPanelController: NSObject { button.isBordered = false button.font = .systemFont(ofSize: 11) button.contentTintColor = .secondaryLabelColor + if hint.action != #selector(newClicked) { + selectionButtons.append(button) + } + if hint.action == #selector(launchClicked) { + button.keyEquivalent = "\r" + } views.append(button) } let footer = NSStackView(views: views) @@ -471,24 +478,28 @@ final class QuickLaunchPanelController: NSObject { private func applyTheme() { guard let content = panel?.contentView, let layer = content.layer else { return } - // Theme colors are dynamic; resolving into CG colors snapshots the - // current appearance, so this runs on every theme and appearance edge. - layer.backgroundColor = Theme.background.usingColorSpace(.sRGB)?.cgColor - layer.borderColor = Theme.divider.usingColorSpace(.sRGB)? - .withAlphaComponent(0.5) - .cgColor + // Publishers can fire outside drawing, where the current appearance + // differs from the panel's. Resolve layer colors in the panel's scope. + content.effectiveAppearance.performAsCurrentDrawingAppearance { + layer.backgroundColor = Theme.background.usingColorSpace(.sRGB)?.cgColor + layer.borderColor = Theme.divider.usingColorSpace(.sRGB)? + .withAlphaComponent(0.5) + .cgColor + } layer.borderWidth = 1 } // MARK: - List updates - private func refilterAndReload() { + private func refilterAndReload(preservingSelection: Bool = true) { + let selectedID = preservingSelection ? selectedEntry?.id : nil refilter() - reload() + reload(selectedID: selectedID) } private func refilter() { let entries = QuickLaunchStore.shared.entries + displayRows.removeAll(keepingCapacity: true) let pattern = query.trimmingCharacters(in: .whitespaces) guard pattern.isEmpty else { displayRows = fuzzyRanked(entries: entries, pattern: pattern) @@ -498,11 +509,11 @@ final class QuickLaunchPanelController: NSObject { // No filter: group the saved order into sections, keeping each // group's first appearance position. Entries without a group share // one "Ungrouped" section, which only exists next to real groups. - if entries.contains(where: { !$0.displayGroup.isEmpty }) { + if entries.contains(where: { !$0.groupKey.isEmpty }) { var groupOrder: [String] = [] var entriesByGroup: [String: [QuickLaunchEntry]] = [:] for entry in entries { - let group = entry.displayGroup + let group = entry.groupKey if entriesByGroup[group] == nil { groupOrder.append(group) entriesByGroup[group] = [] @@ -510,7 +521,9 @@ final class QuickLaunchPanelController: NSObject { entriesByGroup[group]?.append(entry) } for group in groupOrder { - displayRows.append(.header(group)) + displayRows.append(.header(group.isEmpty + ? String(localized: "Ungrouped", comment: "Section title for Quick Launch entries without a group.") + : group)) displayRows.append( contentsOf: entriesByGroup[group, default: []].map { DisplayRow.entry($0) } ) @@ -530,7 +543,7 @@ final class QuickLaunchPanelController: NSObject { var buffer = Self.fuzzyMatcher.makeBuffer() var matches: [(entry: QuickLaunchEntry, score: Double, order: Int)] = [] for (order, entry) in entries.enumerated() { - var candidate = [entry.name, entry.detail] + var candidate = [entry.name, entry.detail, entry.group] .compactMap { $0 } .joined(separator: " ") guard let score = candidate.withUTF8({ bytes in @@ -545,13 +558,15 @@ final class QuickLaunchPanelController: NSObject { return matches } - private func reload() { + private func reload(selectedID: UUID?) { guard let panel else { return } tableView.reloadData() let size = contentSize() listHeightConstraint?.constant = size.height - Self.searchBarHeight - Self.footerHeight - 2 + let top = panel.frame.maxY panel.setContentSize(size) + panel.setFrameOrigin(NSPoint(x: panel.frame.minX, y: top - panel.frame.height)) tableView.sizeLastColumnToFit() if displayRows.isEmpty { @@ -576,12 +591,21 @@ final class QuickLaunchPanelController: NSObject { } else { emptyStateView.isHidden = true scrollView.isHidden = false - // Select the first entry row; group headers are not selectable. - if let firstEntry = displayRows.firstIndex(where: \.isEntry) { - tableView.selectRowIndexes(IndexSet(integer: firstEntry), byExtendingSelection: false) - tableView.scrollRowToVisible(firstEntry) + let selectedRow = selectedID.flatMap { id in + displayRows.indices.first { entry(atRow: $0)?.id == id } + } + ?? displayRows.firstIndex(where: \.isEntry) + if let selectedRow { + tableView.selectRowIndexes(IndexSet(integer: selectedRow), byExtendingSelection: false) + tableView.scrollRowToVisible(selectedRow) } } + updateSelectionButtons() + } + + private func updateSelectionButtons() { + let hasSelection = selectedEntry != nil + for button in selectionButtons { button.isEnabled = hasSelection } } // MARK: - Actions @@ -605,12 +629,13 @@ final class QuickLaunchPanelController: NSObject { } private func launchSelection() { - guard let entry = selectedEntry, let manager else { return } + guard panel?.isVisible == true, let entry = selectedEntry, let manager else { return } close() manager.runQuickLaunchEntry(entry) } @objc private func tableViewClicked() { + guard panel?.isVisible == true else { return } // NSTableView fires this action for any click that selects a row. // With RowButtonTableView the row's buttons now receive their own // mouse-downs, so this normally only sees plain row clicks; the @@ -708,19 +733,19 @@ final class QuickLaunchPanelController: NSObject { } private extension QuickLaunchEntry { - /// The section title an entry sorts under; ungrouped entries share one - /// bucket that only renders next to real groups. - var displayGroup: String { - let trimmed = group?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty - ? String(localized: "Ungrouped", comment: "Section title for Quick Launch entries without a group.") - : trimmed + /// Keep the empty group distinct from a group named "Ungrouped". + var groupKey: String { + group?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } } // MARK: - Table data extension QuickLaunchPanelController: NSTableViewDataSource, NSTableViewDelegate { + func tableViewSelectionDidChange(_ notification: Notification) { + updateSelectionButtons() + } + func numberOfRows(in tableView: NSTableView) -> Int { displayRows.count } @@ -769,10 +794,12 @@ private final class QuickLaunchHeaderView: NSView { super.init(frame: .zero) label.font = .systemFont(ofSize: 11, weight: .semibold) label.textColor = .secondaryLabelColor + label.lineBreakMode = .byTruncatingTail label.translatesAutoresizingMaskIntoConstraints = false addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 9), + label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -9), label.centerYAnchor.constraint(equalTo: centerYAnchor), ]) } @@ -784,6 +811,7 @@ private final class QuickLaunchHeaderView: NSView { func configure(title: String) { label.stringValue = title + toolTip = title setAccessibilityLabel(title) } } @@ -880,6 +908,7 @@ private final class QuickLaunchRowView: NSView { let detail = entry.detail ?? "" detailLabel.stringValue = detail detailLabel.isHidden = detail.isEmpty + toolTip = [entry.name, detail].filter { !$0.isEmpty }.joined(separator: "\n") setAccessibilityLabel( [entry.name, detail.isEmpty ? nil : detail] .compactMap { $0 } diff --git a/mac/zshell/SSHProjectController.swift b/mac/zshell/SSHProjectController.swift index 7bf0b19..a8953c0 100644 --- a/mac/zshell/SSHProjectController.swift +++ b/mac/zshell/SSHProjectController.swift @@ -207,7 +207,7 @@ final class SSHProjectController: NSObject { private var selectedEntry: SSHProjectEntry? { let row = tableView.selectedRow - guard row >= 0, case .entry(let entry) = displayRows[row] else { return nil } + guard displayRows.indices.contains(row), case .entry(let entry) = displayRows[row] else { return nil } return entry } @@ -280,7 +280,6 @@ final class SSHProjectController: NSObject { // One click on a row connects — the dialog's primary "one-click // invoke"; the row's trailing buttons handle edit and delete. tableView.action = #selector(rowClicked) - tableView.doubleAction = #selector(rowClicked) tableView.setAccessibilityLabel(String( localized: "Saved SSH Projects", comment: "Accessibility label of the saved SSH project list." @@ -427,11 +426,11 @@ final class SSHProjectController: NSObject { private func reloadList() { let entries = SSHProjectStore.shared.entries var rows: [DisplayRow] = [] - if entries.contains(where: { !$0.displayGroup.isEmpty }) { + if entries.contains(where: { !$0.groupKey.isEmpty }) { var seenGroups: [String] = [] var entriesByGroup: [String: [SSHProjectEntry]] = [:] for entry in entries { - let group = entry.displayGroup + let group = entry.groupKey if entriesByGroup[group] == nil { seenGroups.append(group) entriesByGroup[group] = [] @@ -439,7 +438,9 @@ final class SSHProjectController: NSObject { entriesByGroup[group]?.append(entry) } for group in seenGroups { - rows.append(.header(group)) + rows.append(.header(group.isEmpty + ? String(localized: "Ungrouped", comment: "Section title for SSH projects without a group.") + : group)) rows.append( contentsOf: entriesByGroup[group, default: []].map { DisplayRow.entry($0) } ) @@ -498,18 +499,24 @@ final class SSHProjectController: NSObject { /// Builds an entry from the form. Throws `SSHEndpoint.ValidationError` for /// bad connection fields; an empty host reports "Enter a host." private func entryFromForm(name: String) throws -> (SSHProjectEntry, SSHEndpoint) { + let portText = portField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let port = Int(portText) + if !portText.isEmpty, port == nil { + throw SSHEndpoint.ValidationError.invalidPort + } let endpoint = try SSHEndpoint( host: hostField.stringValue, user: userField.stringValue.isEmpty ? nil : userField.stringValue, - port: portField.stringValue.isEmpty ? nil : portField.integerValue + port: port ) let directory = directoryField.stringValue .trimmingCharacters(in: .whitespacesAndNewlines) let group = groupField.stringValue .trimmingCharacters(in: .whitespacesAndNewlines) + let existingEntry = SSHProjectStore.shared.entries.first { $0.id == editingEntryID } let entry = SSHProjectEntry( id: editingEntryID ?? UUID(), - name: name, + name: existingEntry?.name ?? name, user: endpoint.user, host: endpoint.host, port: endpoint.port, @@ -526,7 +533,7 @@ final class SSHProjectController: NSObject { // programmatic action dispatch (and is harmless otherwise, since // the two agree for a normal click). let row = tableView.clickedRow >= 0 ? tableView.clickedRow : tableView.selectedRow - guard row >= 0, case .entry(let entry) = displayRows[row] else { return } + guard displayRows.indices.contains(row), case .entry(let entry) = displayRows[row] else { return } // RowButtonTableView hands the rows' buttons their own mouse-downs, // so this action only fires for plain row clicks; the button check // remains as a guard for drifted presses. @@ -594,7 +601,7 @@ final class SSHProjectController: NSObject { } private func connect(_ entry: SSHProjectEntry, endpoint: SSHEndpoint? = nil) { - guard let manager else { return } + guard window?.isVisible == true, let manager else { return } do { let endpoint = try endpoint ?? SSHEndpoint( host: entry.host, user: entry.user, port: entry.port @@ -693,10 +700,12 @@ private final class SSHGroupHeaderView: NSView { super.init(frame: .zero) label.font = .systemFont(ofSize: 11, weight: .semibold) label.textColor = .secondaryLabelColor + label.lineBreakMode = .byTruncatingTail label.translatesAutoresizingMaskIntoConstraints = false addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 9), + label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -9), label.centerYAnchor.constraint(equalTo: centerYAnchor), ]) } @@ -708,6 +717,7 @@ private final class SSHGroupHeaderView: NSView { func configure(title: String) { label.stringValue = title + toolTip = title setAccessibilityLabel(title) } } @@ -729,7 +739,7 @@ private final class SSHProjectRowView: NSView { iconView.setContentHuggingPriority(.defaultHigh, for: .horizontal) titleLabel.font = .systemFont(ofSize: 12.5) titleLabel.lineBreakMode = .byTruncatingTail - titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) detailLabel.font = .systemFont(ofSize: 11) detailLabel.textColor = .tertiaryLabelColor detailLabel.lineBreakMode = .byTruncatingMiddle @@ -818,6 +828,7 @@ private final class SSHProjectRowView: NSView { detail += " · \(directory)" } detailLabel.stringValue = detail + toolTip = [entry.displayName, detail].joined(separator: "\n") setAccessibilityLabel( [entry.displayName, entry.destination] .joined(separator: ", ") @@ -827,6 +838,8 @@ private final class SSHProjectRowView: NSView { /// A 1pt hairline box border drawn around the list area. private final class HairlineBox: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } + override func draw(_ dirtyRect: NSRect) { let path = NSBezierPath(roundedRect: bounds, xRadius: 4, yRadius: 4) path.lineWidth = 1 @@ -841,12 +854,8 @@ private final class HairlineBox: NSView { } private extension SSHProjectEntry { - /// The section title an entry sorts under; ungrouped entries share one - /// bucket rendered with the app's "Ungrouped" label. - var displayGroup: String { - let trimmed = group?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty - ? String(localized: "Ungrouped", comment: "Section title for SSH projects without a group.") - : trimmed + /// Keep the empty group distinct from a group named "Ungrouped". + var groupKey: String { + group?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } } diff --git a/mac/zshell/SessionStore.swift b/mac/zshell/SessionStore.swift index 68061a9..f98dbd5 100644 --- a/mac/zshell/SessionStore.swift +++ b/mac/zshell/SessionStore.swift @@ -73,6 +73,7 @@ struct SessionSnapshot: Codable { /// Opaque sRGB marker color. Stored as a string so unknown future /// values do not invalidate the containing session snapshot. var markerColorHex: String? + var tabGroupID: UUID? /// Per-tab settings applied when this restored tab creates shells. /// Optional decoding preserves every older tab snapshot format. var launchSettingsOverride = TerminalLaunchSettingsOverride() @@ -87,6 +88,7 @@ struct SessionSnapshot: Codable { customName: String? = nil, isPinned: Bool = false, markerColorHex: String? = nil, + tabGroupID: UUID? = nil, launchSettingsOverride: TerminalLaunchSettingsOverride = .init(), contextSessionIndex: Int? = nil ) { @@ -95,12 +97,13 @@ struct SessionSnapshot: Codable { self.customName = customName self.isPinned = isPinned self.markerColorHex = markerColorHex + self.tabGroupID = tabGroupID self.launchSettingsOverride = launchSettingsOverride self.contextSessionIndex = contextSessionIndex } enum CodingKeys: String, CodingKey { - case layout, focusedPaneIndex, customName, isPinned, markerColorHex + case layout, focusedPaneIndex, customName, isPinned, markerColorHex, tabGroupID case launchSettingsOverride case contextSessionIndex, columns, focusedColumn, focusedRow } @@ -114,6 +117,7 @@ struct SessionSnapshot: Codable { customName = try? container.decode(String.self, forKey: .customName) isPinned = (try? container.decode(Bool.self, forKey: .isPinned)) ?? false markerColorHex = try? container.decode(String.self, forKey: .markerColorHex) + tabGroupID = try? container.decode(UUID.self, forKey: .tabGroupID) launchSettingsOverride = (try? container.decode( TerminalLaunchSettingsOverride.self, @@ -151,6 +155,7 @@ struct SessionSnapshot: Codable { customName = try? container.decode(String.self, forKey: .customName) isPinned = false markerColorHex = try? container.decode(String.self, forKey: .markerColorHex) + tabGroupID = nil launchSettingsOverride = .init() contextSessionIndex = nil return @@ -163,6 +168,7 @@ struct SessionSnapshot: Codable { customName = nil isPinned = false markerColorHex = nil + tabGroupID = nil launchSettingsOverride = .init() contextSessionIndex = nil } @@ -176,6 +182,7 @@ struct SessionSnapshot: Codable { try container.encode(true, forKey: .isPinned) } try container.encodeIfPresent(markerColorHex, forKey: .markerColorHex) + try container.encodeIfPresent(tabGroupID, forKey: .tabGroupID) if launchSettingsOverride != .init() { try container.encode( launchSettingsOverride, forKey: .launchSettingsOverride @@ -241,6 +248,7 @@ struct SessionSnapshot: Codable { /// ungrouped. Optional so snapshots written before grouping existed /// still decode. var groupID: UUID? + var tabGroups: [SessionTabGroup] = [] /// Project values inherited by newly created terminals. Empty settings /// are omitted while decoding still accepts snapshots that lack them. var launchSettings = TerminalLaunchSettings() @@ -251,7 +259,7 @@ struct SessionSnapshot: Codable { var selectedTabIndex: Int? enum CodingKeys: String, CodingKey { - case customName, isPinned, markerColorHex, customDirectory, groupID, launchSettings, location, tabs, selectedTabIndex + case customName, isPinned, markerColorHex, customDirectory, groupID, tabGroups, launchSettings, location, tabs, selectedTabIndex } init( @@ -260,6 +268,7 @@ struct SessionSnapshot: Codable { markerColorHex: String? = nil, customDirectory: String?, groupID: UUID? = nil, + tabGroups: [SessionTabGroup] = [], launchSettings: TerminalLaunchSettings = .init(), location: ProjectLocation? = nil, tabs: [TabSnapshot], @@ -270,6 +279,7 @@ struct SessionSnapshot: Codable { self.markerColorHex = markerColorHex self.customDirectory = customDirectory self.groupID = groupID + self.tabGroups = tabGroups self.launchSettings = launchSettings self.location = location self.tabs = tabs @@ -285,6 +295,7 @@ struct SessionSnapshot: Codable { String.self, forKey: .customDirectory ) groupID = try container.decodeIfPresent(UUID.self, forKey: .groupID) + tabGroups = try container.decodeIfPresent([SessionTabGroup].self, forKey: .tabGroups) ?? [] launchSettings = try container.decodeIfPresent( TerminalLaunchSettings.self, forKey: .launchSettings ) ?? .init() @@ -306,6 +317,7 @@ struct SessionSnapshot: Codable { try container.encodeIfPresent(markerColorHex, forKey: .markerColorHex) try container.encodeIfPresent(customDirectory, forKey: .customDirectory) try container.encodeIfPresent(groupID, forKey: .groupID) + if !tabGroups.isEmpty { try container.encode(tabGroups, forKey: .tabGroups) } if launchSettings != .init() { try container.encode(launchSettings, forKey: .launchSettings) } diff --git a/mac/zshell/Settings/CommandShortcutRecorder.swift b/mac/zshell/Settings/CommandShortcutRecorder.swift index 2145e77..45cafe6 100644 --- a/mac/zshell/Settings/CommandShortcutRecorder.swift +++ b/mac/zshell/Settings/CommandShortcutRecorder.swift @@ -7,10 +7,8 @@ import AppKit import Carbon.HIToolbox /// Records an in-app command shortcut: click to arm, then press the chord. -/// Unlike the Quick Terminal recorder there is no live hotkey to suspend, but -/// the flow is the same — Escape cancels, an unusable chord beeps and keeps -/// recording, and a chord the settings layer refuses (a conflict) leaves the -/// old binding in place. +/// Escape cancels, an unusable chord beeps and keeps recording, and a chord +/// the settings layer refuses (a conflict) leaves the old binding in place. final class CommandShortcutRecorder: NSButton { /// Called with the recorded chord; return `false` to reject it, which /// beeps and keeps the recorder armed. @@ -41,6 +39,8 @@ final class CommandShortcutRecorder: NSButton { guard !isRecording else { return true } isRecording = true title = String(localized: "Press shortcut") + // A conflicting global chord must reach the recorder for validation. + GlobalTerminalOverlay.shared.beginHotkeyRecording() return true } @@ -48,6 +48,7 @@ final class CommandShortcutRecorder: NSButton { guard super.resignFirstResponder() else { return false } guard isRecording else { return true } isRecording = false + GlobalTerminalOverlay.shared.endHotkeyRecording() updateTitle() return true } diff --git a/mac/zshell/Settings/FontThickenPreviewView.swift b/mac/zshell/Settings/FontThickenPreviewView.swift index 70045fb..a276d19 100644 --- a/mac/zshell/Settings/FontThickenPreviewView.swift +++ b/mac/zshell/Settings/FontThickenPreviewView.swift @@ -41,6 +41,11 @@ final class FontThickenPreviewView: NSView { override var isFlipped: Bool { true } + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } + /// The sample's height follows the font, so both settings arrive together /// and the layout is invalidated with the drawing. func configure( diff --git a/mac/zshell/Settings/SettingsAppearancePane.swift b/mac/zshell/Settings/SettingsAppearancePane.swift index 6c43c7e..1247e89 100644 --- a/mac/zshell/Settings/SettingsAppearancePane.swift +++ b/mac/zshell/Settings/SettingsAppearancePane.swift @@ -183,6 +183,7 @@ final class SettingsAppearancePane: SettingsPaneViewController { interfaceScaleRow.setValue(settings.interfaceScale) thickenSwitch.isOn = settings.fontThicken thickenStrengthRow.setValue(Double(settings.fontThickenStrength)) + thickenStrengthRow.setEnabled(settings.fontThicken) lineHeightRow.setValue(settings.terminalLineHeight) paneFocusRingSwitch.isOn = settings.showPaneFocusRing paneFocusRingOpacityRow.setValue(settings.paneFocusRingOpacity) diff --git a/mac/zshell/Settings/SettingsRows.swift b/mac/zshell/Settings/SettingsRows.swift index 4b4569a..49bd886 100644 --- a/mac/zshell/Settings/SettingsRows.swift +++ b/mac/zshell/Settings/SettingsRows.swift @@ -244,10 +244,8 @@ final class SettingsSliderRow: NSView { valueLabel.widthAnchor.constraint(equalToConstant: SettingsMetrics.sliderValueWidth), ]) - if let accessibilityLabel { - slider.setAccessibilityLabel(accessibilityLabel) - stepper?.setAccessibilityLabel(accessibilityLabel) - } + slider.setAccessibilityLabel(accessibilityLabel ?? title) + stepper?.setAccessibilityLabel(accessibilityLabel ?? title) } @available(*, unavailable) diff --git a/mac/zshell/Settings/SettingsShortcutsPane.swift b/mac/zshell/Settings/SettingsShortcutsPane.swift index 87540ed..599ec03 100644 --- a/mac/zshell/Settings/SettingsShortcutsPane.swift +++ b/mac/zshell/Settings/SettingsShortcutsPane.swift @@ -18,8 +18,10 @@ final class SettingsShortcutsPane: SettingsPaneViewController { private lazy var resetRow = SettingsButtonRow( title: String(localized: "Restore Default Shortcuts") ) { [weak self] in - self?.settings.resetCommandShortcuts() - self?.conflictLabel.stringValue = "" + guard let self else { return } + settings.resetCommandShortcuts() + conflictLabel.stringValue = "" + shortcutsGroup?.setRowHidden(true, at: conflictRowIndex) } override func makeGroups() -> [NSView] { diff --git a/mac/zshell/SidebarView.swift b/mac/zshell/SidebarView.swift index 93b3643..258722b 100644 --- a/mac/zshell/SidebarView.swift +++ b/mac/zshell/SidebarView.swift @@ -6,458 +6,27 @@ import AppKit import SwiftUI -/// Vertical tab strip listing projects, otty-style. Each row is a project; -/// its sessions show as horizontal tabs in the main header. +/// The legacy workspace retains only its persisted width binding; the +/// sidebar contents and interaction are owned by the native view. struct SidebarView: View { - @ObservedObject var manager: TerminalManager + let manager: TerminalManager + let tabDrag: TabSplitDragCoordinator let bottomBarHeight: CGFloat @ObservedObject private var settings = AppSettings.shared - @ObservedObject private var themeChanges = Theme.changes - @ObservedObject private var groupStore = ProjectGroupStore.shared - @Environment(\.colorScheme) private var colorScheme @AppStorage("leftSidebarWidth") private var width: Double = 220 - @State private var draggedProjectID: UUID? - @State private var projectFrames: [UUID: CGRect] = [:] - @State private var pendingRenamingGroupID: UUID? - - /// Projects without a group, in sidebar order (pinned first). - private var ungroupedProjects: [(index: Int, project: Project)] { - manager.projects.enumerated().compactMap { entry in - entry.element.groupID == nil - ? (index: entry.offset, project: entry.element) - : nil - } - } - - private func projects(in group: ProjectGroup) -> [Project] { - manager.projects.filter { $0.groupID == group.id } - } - - private var sidebarWidthRange: ClosedRange { - (160 * settings.interfaceScale)...(400 * settings.interfaceScale) - } - - private var defaultSidebarWidth: Double { - 220 * settings.interfaceScale - } var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Header-height strip housing the traffic-light buttons and the - // control for collapsing this sidebar. - HStack(spacing: 0) { - WindowDragArea() - .frame(maxWidth: .infinity) - if manager.isFPSCounterVisible { - FPSBadge() - .padding(.trailing, 8) - } - ChromeIconButton( - systemImage: "sidebar.left", - tooltip: "Toggle Left Sidebar (⌘B)" - ) { - manager.toggleLeftSidebar() - } - } - .padding(.trailing, 8) - .frame(height: 38) - - ScrollView { - VStack(spacing: 3) { - // Ungrouped projects keep the pre-grouping layout: pinned - // rows first, in their existing order, at full indent. - ForEach(ungroupedProjects, id: \.project.id) { entry in - let project = entry.project - SidebarProjectRow( - project: project, - index: entry.index, - isSelected: project.id == manager.selectedProjectID, - select: { manager.selectedProjectID = project.id }, - setPinned: { manager.setPinned($0, for: project) }, - close: { manager.close(project) }, - moveToGroup: { manager.moveProject(project, to: $0) }, - isDragging: draggedProjectID == project.id, - onDrag: { updateProjectDrag(source: project.id, location: $0) }, - onDragEnded: endProjectDrag, - fontSize: settings.sidebarFontSize * settings.interfaceScale - ) - .background { - GeometryReader { proxy in - Color.clear.preference( - key: ProjectFramePreferenceKey.self, - value: [project.id: proxy.frame(in: .global)] - ) - } - } - } - - // Groups follow, in saved order; each renders a header - // row (click to collapse) over its indented projects. - ForEach(groupStore.groups) { group in - SidebarGroupHeader( - group: group, - projectCount: projects(in: group).count, - isRenaming: pendingRenamingGroupID == group.id, - toggleCollapsed: { - var updated = group - updated.isCollapsed.toggle() - groupStore.update(updated) - }, - beginRename: { pendingRenamingGroupID = group.id }, - endRename: { pendingRenamingGroupID = nil }, - applyRename: { newValue in - var updated = group - updated.name = newValue - groupStore.update(updated) - }, - newProjectInGroup: { manager.newProject(in: group) }, - changeFolder: { - pickGroupFolder(group: group, store: groupStore) - }, - removeGroup: { - for project in projects(in: group) { - manager.moveProject(project, to: nil) - } - groupStore.remove(group) - } - ) - - if !group.isCollapsed { - ForEach(projects(in: group)) { project in - SidebarProjectRow( - project: project, - index: nil, - isSelected: project.id == manager.selectedProjectID, - select: { manager.selectedProjectID = project.id }, - setPinned: { manager.setPinned($0, for: project) }, - close: { manager.close(project) }, - moveToGroup: { manager.moveProject(project, to: $0) }, - isDragging: draggedProjectID == project.id, - onDrag: { updateProjectDrag(source: project.id, location: $0) }, - onDragEnded: endProjectDrag, - fontSize: settings.sidebarFontSize * settings.interfaceScale - ) - .background { - GeometryReader { proxy in - Color.clear.preference( - key: ProjectFramePreferenceKey.self, - value: [project.id: proxy.frame(in: .global)] - ) - } - } - } - } - } - } - .padding(.horizontal, 8) - .padding(.top, 8) - } - .background { - SidebarFolderDropView(manager: manager) - } - - HStack(spacing: 2) { - SidebarFooterButton( - systemImage: "plus", - tooltip: "New Project (⌘N)" - ) { manager.newProject() } - SidebarFooterButton( - systemImage: "folder.badge.plus", - tooltip: "New Group" - ) { - showNewGroupMenu() - } - SidebarFooterButton( - systemImage: "network", - tooltip: "New SSH Project" - ) { manager.promptForSSHProject() } - SidebarFooterButton( - systemImage: "bolt", - tooltip: "Quick Launch (⌘O)" - ) { manager.toggleQuickLaunch() } - Spacer() - SidebarFooterButton( - systemImage: "exclamationmark.bubble", - tooltip: "Send Feedback", - tooltipAlignment: .trailing - ) { - NSWorkspace.shared.open( - URL(string: "https://github.com/wzz6423/zshell/issues/new")! - ) - } - SidebarFooterButton( - systemImage: "gearshape", - tooltip: "Settings (⌘,)", - tooltipAlignment: .trailing - ) { SettingsWindowController.shared.show() } - } - .padding(.horizontal, 8) - .frame(height: bottomBarHeight) - .overlay(alignment: .top) { - Rectangle() - .fill(Color(nsColor: Theme.divider)) - .frame(height: 1) - } - } - .frame(width: width) - .background { - // Zshell's built-in Default themes keep the native translucent - // sidebar material; every other theme — including the GitHub - // originals they're based on — paints its flat sidebar shade so - // the strip follows the palette. - if Theme.isDefault(dark: colorScheme == .dark) { - VisualEffectView(material: .sidebar) - } else { - Color(nsColor: Theme.sidebar) - } - } - // Hairline between sidebar and content: themes fill both with the - // same background, so the boundary needs its own line. The built-in - // Defaults keep their material fill, whose contrast already draws - // the edge. - .overlay(alignment: .trailing) { - if !Theme.isDefault(dark: colorScheme == .dark) { - Rectangle() - .fill(Color(nsColor: Theme.divider)) - .frame(width: 1) - .allowsHitTesting(false) - } - } - .overlay(alignment: .trailing) { - SidebarResizeHandle( - edge: .trailing, - width: $width, - range: sidebarWidthRange, - defaultWidth: defaultSidebarWidth, - fontSize: settings.sidebarFontSize - ) - } - .onPreferenceChange(ProjectFramePreferenceKey.self) { projectFrames = $0 } - } - - private func updateProjectDrag(source: UUID, location: CGPoint) { - draggedProjectID = source - NSCursor.closedHand.set() - guard let target = projectFrames.first(where: { - $0.key != source && $0.value.contains(location) - })?.key else { return } - withAnimation(.easeInOut(duration: 0.12)) { - manager.moveProject(source, to: target) - } - } - - private func endProjectDrag() { - draggedProjectID = nil - NSCursor.arrow.set() - } - - /// The "+" group button: plain groups start empty, folder groups anchor a - /// folder picked here so the group's name and directory both follow it. - private func showNewGroupMenu() { - let menu = NSMenu() - let plain = NSMenuItem( - title: String(localized: "Plain Group", comment: "Menu item creating a sidebar project group without a folder."), - action: #selector(SidebarGroupMenuTarget.newPlainGroup(_:)), - keyEquivalent: "" - ) - plain.target = menuTarget - menu.addItem(plain) - let folder = NSMenuItem( - title: String(localized: "Folder Group…", comment: "Menu item creating a sidebar project group anchored to a folder."), - action: #selector(SidebarGroupMenuTarget.newFolderGroup(_:)), - keyEquivalent: "" - ) - folder.target = menuTarget - menu.addItem(folder) - - menuTarget.kind = .none - menuTarget.completion = { kind in - if case .folder = kind { - pickFolder { path in - guard let path else { return } - let group = ProjectGroup( - name: ProjectGroup.defaultName(for: .folder(path: path)), - kind: .folder(path: path) - ) - groupStore.add(group) - } - } else { - let group = ProjectGroup(name: ProjectGroup.defaultName(for: .plain), kind: .plain) - groupStore.add(group) + ProjectSidebarRepresentable(manager: manager, tabDrag: tabDrag, bottomBarHeight: bottomBarHeight) + .frame(width: width) + .overlay(alignment: .trailing) { + SidebarResizeHandle( + edge: .trailing, + width: $width, + range: (160 * settings.interfaceScale)...(400 * settings.interfaceScale), + defaultWidth: 220 * settings.interfaceScale, + fontSize: settings.sidebarFontSize + ) } - } - menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) - } - - /// Lets the user re-anchor a folder group. - private func pickGroupFolder(group: ProjectGroup, store: ProjectGroupStore) { - pickFolder(initial: group.folderPath) { path in - guard let path else { return } - var updated = group - updated.kind = .folder(path: path) - updated.name = ProjectGroup.defaultName(for: .folder(path: path)) - store.update(updated) - } - } - - private func pickFolder( - initial: String? = nil, - completion: @escaping (String?) -> Void - ) { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.prompt = String(localized: "Choose", comment: "Button in the project directory picker.") - if let initial { - panel.directoryURL = URL(fileURLWithPath: initial, isDirectory: true) - } - let apply: (NSApplication.ModalResponse) -> Void = { response in - completion(response == .OK ? panel.url?.path : nil) - } - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - panel.beginSheetModal(for: window, completionHandler: apply) - } else { - apply(panel.runModal()) - } - } - - /// Target for the NSMenu shown from the new-group button; SwiftUI menus - /// can't be popped up imperatively, so this tiny object carries the two - /// actions and hands the chosen kind back through `completion`. - @State private var menuTarget = SidebarGroupMenuTarget() -} - -@MainActor -private final class SidebarGroupMenuTarget: NSObject { - enum PendingKind { - case none - case plain - case folder - } - - var kind: PendingKind = .none - var completion: ((PendingKind) -> Void)? - - @objc func newPlainGroup(_ sender: NSMenuItem) { - completion?(.plain) - completion = nil - } - - @objc func newFolderGroup(_ sender: NSMenuItem) { - completion?(.folder) - completion = nil - } -} - -private struct SidebarFolderDropView: NSViewRepresentable { - let manager: TerminalManager - - func makeNSView(context: Context) -> SidebarFolderDropDestinationView { - let view = SidebarFolderDropDestinationView() - view.manager = manager - return view - } - - func updateNSView(_ view: SidebarFolderDropDestinationView, context: Context) { - view.manager = manager - } -} - -@MainActor -private final class SidebarFolderDropDestinationView: NSView { - weak var manager: TerminalManager? - private var isDropTarget = false { - didSet { - guard isDropTarget != oldValue else { return } - needsDisplay = true - } - } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - registerForDraggedTypes([.fileURL]) - setAccessibilityElement(false) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func hitTest(_ point: NSPoint) -> NSView? { nil } - - override func draw(_ dirtyRect: NSRect) { - guard isDropTarget else { return } - let path = NSBezierPath( - roundedRect: bounds.insetBy(dx: 4, dy: 4), - xRadius: 8, - yRadius: 8 - ) - NSColor.controlAccentColor.withAlphaComponent(0.12).setFill() - path.fill() - NSColor.controlAccentColor.withAlphaComponent(0.7).setStroke() - path.lineWidth = 2 - path.setLineDash([5, 4], count: 2, phase: 0) - path.stroke() - } - - override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - updateDropState(sender) - } - - override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { - updateDropState(sender) - } - - override func draggingExited(_ sender: NSDraggingInfo?) { - isDropTarget = false - } - - override func draggingEnded(_ sender: NSDraggingInfo) { - isDropTarget = false - } - - override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - isDropTarget = false - guard let directories = directories(from: sender), !directories.isEmpty, - let manager else { - NSSound.beep() - announce(String(localized: "Only folders can be added as projects.")) - return false - } - - let createdCount = manager.openOrFocusDirectories(directories) - let message = createdCount == 0 - ? String(localized: "Project already open. Focused it in the sidebar.") - : String(localized: "Added folder as a project.") - announce(message) - return true - } - - private func updateDropState(_ sender: NSDraggingInfo) -> NSDragOperation { - let acceptsDrop = directories(from: sender)?.isEmpty == false - isDropTarget = acceptsDrop - return acceptsDrop ? .copy : [] - } - - private func directories(from sender: NSDraggingInfo) -> [String]? { - let pasteboard = sender.draggingPasteboard - guard pasteboard.canReadObject( - forClasses: [NSURL.self], - options: [.urlReadingFileURLsOnly: true] - ) else { return nil } - return ZshellApplicationDelegate.directories(from: pasteboard) - } - - private func announce(_ message: String) { - NSAccessibility.post( - element: NSApp as Any, - notification: .announcementRequested, - userInfo: [ - .announcement: message, - .priority: NSAccessibilityPriorityLevel.medium.rawValue, - ] - ) } } @@ -495,455 +64,3 @@ struct ChromeIconButton: View { .tooltip(tooltip, edge: tooltipEdge, alignment: tooltipAlignment) } } - -/// Live frames-per-second readout in the header strip, fed by `FPSCounter`. -/// It exists only while the manager's toggle is on, so the counter starts -/// when the badge appears and stops when it leaves the hierarchy. -private struct FPSBadge: View { - @StateObject private var counter = FPSCounter() - - var body: some View { - Text("\(counter.fps) fps") - .font(.system(size: 10, weight: .medium)) - .monospacedDigit() - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 5) - .fill(Color.primary.opacity(0.07)) - ) - .onAppear { counter.start() } - .onDisappear { counter.stop() } - } -} - -private struct SidebarFooterButton: View { - let systemImage: String - let tooltip: LocalizedStringKey - /// Buttons near the sidebar's right edge anchor `.trailing` so the label - /// grows inward instead of off-panel. - var tooltipAlignment: HorizontalAlignment = .leading - let action: () -> Void - - var body: some View { - ChromeIconButton( - systemImage: systemImage, - tooltip: tooltip, - tooltipEdge: .above, - tooltipAlignment: tooltipAlignment, - action: action - ) - } -} - -private struct ProjectFramePreferenceKey: PreferenceKey { - static let defaultValue: [UUID: CGRect] = [:] - - static func reduce(value: inout [UUID: CGRect], nextValue: () -> [UUID: CGRect]) { - value.merge(nextValue()) { $1 } - } -} - -private struct SidebarProjectRow: View { - @ObservedObject var project: Project - @ObservedObject private var themeChanges = Theme.changes - /// Sidebar position for the ⌘N hint; nil for grouped rows, which do not - /// claim a global shortcut slot. - let index: Int? - let isSelected: Bool - let select: () -> Void - let setPinned: (Bool) -> Void - let close: () -> Void - /// Reparents the project under another sidebar group; nil removes it - /// from its group. Wired by the owner view. - var moveToGroup: ((ProjectGroup?) -> Void)? - let isDragging: Bool - let onDrag: (CGPoint) -> Void - let onDragEnded: () -> Void - let fontSize: Double - - @State private var isHovering = false - @State private var isRenaming = false - @State private var renameDraft = "" - @FocusState private var renameFocused: Bool - - var body: some View { - Group { - if isRenaming { - rowContent - } else { - Button(action: select) { - rowContent - } - .buttonStyle(.plain) - // Double-click starts the inline rename, the same - // affordance the tab strip and the context menu's - // "Rename…" entry offer. Attached to the button itself - // because a button consumes clicks before gestures on - // enclosing views get a chance to recognize them. - .onTapGesture(count: 2) { beginRename() } - .highPriorityGesture( - DragGesture(minimumDistance: 4, coordinateSpace: .global) - .onChanged { onDrag($0.location) } - .onEnded { _ in onDragEnded() } - ) - } - } - .opacity(isDragging ? 0.65 : 1) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(isSelected ? Color.primary.opacity(0.09) : (isHovering ? Color.primary.opacity(0.04) : .clear)) - ) - .overlay { - if !isRenaming { - MiddleClickCatcher(action: close) - } - } - .onHover { isHovering = $0 } - .background { - AppKitContextMenuMonitor(items: projectContextMenuItems) - } - } - - private var projectContextMenuItems: [AppKitContextMenuItem] { - var items: [AppKitContextMenuItem] = [ - .action(title: String(localized: project.isPinned ? "Unpin Project" : "Pin Project")) { - setPinned(!project.isPinned) - }, - .separator, - .action(title: String(localized: "Rename…"), handler: beginRename), - ] - if project.customName != nil { - items.append(.action(title: String(localized: "Use Automatic Title")) { - project.customName = nil - }) - } - items.append(.separator) - items.append(moveToGroupMenuItem) - items.append(.separator) - items.append(.action(title: String(localized: "Set Color Marker…")) { - ProjectTabColorPanelController.shared.present(project: project) - }) - if project.markerColor != nil { - items.append(.action(title: String(localized: "Remove Color Marker")) { - project.markerColor = nil - }) - } - items.append(.separator) - items.append(.action(title: String(localized: "Set Project Directory…"), handler: pickProjectDirectory)) - if project.customDirectory != nil { - items.append(.action(title: String(localized: "Use Automatic Directory")) { - project.customDirectory = nil - }) - } - items.append(.separator) - items.append(.action(title: String(localized: "Close Project"), handler: close)) - return items - } - - /// "Move to Group" submenu: one entry per existing group, plus the - /// remove-from-group action for grouped projects. - private var moveToGroupMenuItem: AppKitContextMenuItem { - let groups = ProjectGroupStore.shared.groups - var entries: [AppKitContextMenuItem] = groups.map { group in - .action( - title: group.name, - enabled: project.groupID != group.id - ) { moveToGroup?(group) } - } - if !groups.isEmpty { - entries.append(.separator) - } - entries.append(.action( - title: String(localized: "Remove from Group", comment: "Menu item taking a project out of its sidebar group."), - enabled: project.groupID != nil - ) { moveToGroup?(nil) } - ) - return .submenu( - title: String(localized: "Move to Group", comment: "Menu item grouping a sidebar project."), - enabled: !groups.isEmpty || project.groupID != nil, - items: entries - ) - } - - /// Lets the user pin the project's directory — the root the file tree - /// and git panels anchor to instead of the automatic closest-git-repo. - private func pickProjectDirectory() { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.prompt = String(localized: "Choose", comment: "Button in the project directory picker.") - panel.message = String( - localized: "Choose the directory for “\(project.name)”.", - comment: "Message in the project directory picker. The placeholder is a project name." - ) - if let current = project.customDirectory - ?? project.selectedSession?.currentDirectoryPath { - panel.directoryURL = URL(fileURLWithPath: current, isDirectory: true) - } - let apply: (NSApplication.ModalResponse) -> Void = { response in - guard response == .OK, let url = panel.url else { return } - project.customDirectory = url.path - } - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - panel.beginSheetModal(for: window, completionHandler: apply) - } else { - apply(panel.runModal()) - } - } - - private var rowContent: some View { - HStack(spacing: 8) { - Image(systemName: "folder") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(isSelected ? Color(nsColor: Theme.accent) : .secondary) - .frame(width: max(14, fontSize), alignment: .center) - - if let markerColor = project.markerColor { - Image(systemName: "tag.fill") - .font(.system(size: 8, weight: .semibold)) - .foregroundStyle(Color(nsColor: markerColor.nsColor)) - .accessibilityHidden(true) - } - - VStack(alignment: .leading, spacing: 1) { - if isRenaming { - TextField("", text: $renameDraft) - .textFieldStyle(.plain) - .font(.system(size: projectTitleFontSize, weight: .medium)) - .focused($renameFocused) - .onSubmit(commitRename) - .onExitCommand { isRenaming = false } - .onChange(of: renameFocused) { - if !renameFocused, isRenaming { - commitRename() - } - } - } else { - Text(project.name) - .font(.system(size: projectTitleFontSize)) - .foregroundStyle(isSelected ? .primary : .secondary) - .lineLimit(1) - } - subtitle - } - - Spacer(minLength: 0) - - if let rollup = project.agentRollup, !isRenaming { - AgentStatusBadgeRepresentable(rollup: rollup) - .fixedSize() - } - - // Fixed trailing slot: close and the ⌘N hint share the same - // width so hover does not reflow the row. Continuous title - // updates from the terminal re-render the strip; without a - // stable slot that reflow reads as jitter under the pointer. - ZStack(alignment: .trailing) { - if isHovering, !isRenaming { - Button(action: close) { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.secondary) - .frame(width: 16, height: 16) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } else if let index, index < 9, !isRenaming { - Text(verbatim: "⌘\(index + 1)") - .font(.system(size: supportingFontSize)) - .foregroundStyle(.tertiary) - } - } - // Grows with the sidebar font so the shortcut hint and close - // button keep their slot instead of crowding the title. - .frame( - width: 24 * max(1, sidebarFontScale), - height: 16 * max(1, sidebarFontScale), - alignment: .trailing - ) - } - .padding(.leading, index == nil ? 18 : 8) - .padding(.trailing, 8) - .padding(.vertical, 6) - .contentShape(RoundedRectangle(cornerRadius: 6)) - .accessibilityValue(markerAccessibilityValue) - } - - private var markerAccessibilityValue: String { - guard let markerColor = project.markerColor else { - return String(localized: "No color marker") - } - return String( - localized: "Color marker \(markerColor.displayValue)", - comment: "Accessibility value for a project or tab color marker. The placeholder is an sRGB hex color." - ) - } - - private func beginRename() { - renameDraft = project.name - isRenaming = true - DispatchQueue.main.async { - renameFocused = true - } - } - - private func commitRename() { - project.customName = Project.normalizedCustomName(renameDraft) - isRenaming = false - } - - @ViewBuilder - private var subtitle: some View { - if project.sessions.count > 1 { - Text("\(project.sessions.count) sessions") - .font(.system(size: supportingFontSize)) - .foregroundStyle(.tertiary) - .lineLimit(1) - } else if let session = project.selectedSession { - SessionDirectoryLabel(session: session, fontSize: supportingFontSize) - } - } - - private var supportingFontSize: Double { - 10 * sidebarFontScale - } - - /// Match the file-tree label's designed 11.5 pt size while following the - /// shared sidebar font-size setting. - private var projectTitleFontSize: Double { - 11.5 * sidebarFontScale - } - - private var sidebarFontScale: Double { - fontSize / AppSettings.defaultSidebarFontSize - } -} - -/// Small subtitle showing a session's current directory; separate view so -/// it observes the session's own published working directory. -private struct SessionDirectoryLabel: View { - @ObservedObject var session: TerminalSession - let fontSize: Double - - var body: some View { - if let dir = session.directoryLabel { - Text(dir) - .font(.system(size: fontSize)) - .foregroundStyle(.tertiary) - .lineLimit(1) - } - } -} -/// Sidebar section header for a project group: collapse toggle, the group's -/// icon (plain tray vs anchored folder), an inline rename field, and the -/// group context menu. The group's kind decides where sessions opened from -/// it start — home for a plain group, its folder for a folder group. -private struct SidebarGroupHeader: View { - let group: ProjectGroup - let projectCount: Int - let isRenaming: Bool - let toggleCollapsed: () -> Void - let beginRename: () -> Void - let endRename: () -> Void - /// Commits a new group name (already trimmed by the caller's store). - let applyRename: (String) -> Void - let newProjectInGroup: () -> Void - let changeFolder: () -> Void - let removeGroup: () -> Void - - @State private var renameDraft = "" - @FocusState private var renameFocused: Bool - - var body: some View { - Group { - if isRenaming { - headerContent - } else { - Button(action: toggleCollapsed) { - headerContent - } - .buttonStyle(.plain) - .onTapGesture(count: 2) { beginRename() } - } - } - .background { - AppKitContextMenuMonitor(items: groupContextMenuItems) - } - } - - private var groupContextMenuItems: [AppKitContextMenuItem] { - var items: [AppKitContextMenuItem] = [ - .action(title: String( - localized: "New Project in Group", - comment: "Group menu item creating a project that opens in the group's directory." - )) { newProjectInGroup() }, - .separator, - .action(title: String(localized: "Rename…"), handler: beginRename), - ] - if group.folderPath != nil { - items.append(.action(title: String( - localized: "Change Folder…", - comment: "Group menu item re-anchoring a folder group to another folder." - )) { changeFolder() }) - } - items.append(contentsOf: [ - .separator, - .action(title: String( - localized: "Remove Group", - comment: "Group menu item deleting the group; its projects stay open, ungrouped." - )) { removeGroup() }, - ]) - return items - } - - private var headerContent: some View { - HStack(spacing: 8) { - Image(systemName: group.isCollapsed ? "chevron.right" : "chevron.down") - .font(.system(size: 8, weight: .semibold)) - .foregroundStyle(.tertiary) - .frame(width: 10) - .accessibilityHidden(true) - - Image(systemName: group.folderPath == nil ? "tray.full" : "folder") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.secondary) - .frame(width: max(14, fontSize), alignment: .center) - - if isRenaming { - TextField("", text: $renameDraft) - .textFieldStyle(.plain) - .font(.system(size: fontSize, weight: .medium)) - .focused($renameFocused) - .onSubmit(commitRename) - .onExitCommand { endRename() } - .onChange(of: renameFocused) { - if !renameFocused, isRenaming { commitRename() } - } - } else { - Text(group.name) - .font(.system(size: fontSize, weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(1) - Spacer(minLength: 0) - Text("\(projectCount)") - .font(.system(size: fontSize - 1.5)) - .foregroundStyle(.tertiary) - } - } - .padding(.leading, 8) - .padding(.trailing, 8) - .padding(.vertical, 3) - .contentShape(Rectangle()) - } - - private func commitRename() { - renameDraft = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) - if !renameDraft.isEmpty { applyRename(renameDraft) } - endRename() - } - - private var fontSize: Double { 10.5 } -} diff --git a/mac/zshell/SourceTextEditor.swift b/mac/zshell/SourceTextEditor.swift index 710fecf..7bdb043 100644 --- a/mac/zshell/SourceTextEditor.swift +++ b/mac/zshell/SourceTextEditor.swift @@ -469,11 +469,17 @@ final class FocusReportingTextView: STTextView { breakUndoCoalescing() } - for edit in edits.sorted(by: { $0.range.location > $1.range.location }) { - replaceCharacters(in: edit.textRange, with: edit.replacement) + let orderedEdits = edits.sorted(by: { $0.range.location > $1.range.location }) + for edit in orderedEdits { updateSelectionStates(&updatedStates, after: edit) } registerSelectionUndo(.restoreBeforeEdit, before: states, after: updatedStates) + for edit in orderedEdits { + replaceCharacters(in: edit.textRange, with: edit.replacement) + } + registerSelectionUndo( + .restoreBeforeEdit, before: states, after: updatedStates, restoresSelection: false + ) restoreSelections(updatedStates) } @@ -492,23 +498,30 @@ final class FocusReportingTextView: STTextView { } } - /// Registers the selection half of the newline undo, so multi-cursor - /// selections round-trip: undo lands on the pre-edit selections, redo on - /// the post-edit ones. Registered inside the edit's undo group and after - /// the text handlers, so undo/redo apply text first and the restored - /// ranges map onto the reverted or re-applied text. - private func registerSelectionUndo(_ action: SelectionUndoAction, before: [SelectionState], after: [SelectionState]) { + /// Paired around the text edits so both undo and redo restore selections + /// after restoring the text their ranges belong to. The other half only + /// registers the inverse at the far end of the next undo group. + private func registerSelectionUndo( + _ action: SelectionUndoAction, + before: [SelectionState], + after: [SelectionState], + restoresSelection: Bool = true + ) { guard allowsUndo, let undoManager, undoManager.isUndoRegistrationEnabled else { return } switch action { case .restoreBeforeEdit: undoManager.registerUndo(withTarget: self) { textView in - textView.restoreSelections(before) - textView.registerSelectionUndo(.restoreAfterEdit, before: before, after: after) + if restoresSelection { textView.restoreSelections(before) } + textView.registerSelectionUndo( + .restoreAfterEdit, before: before, after: after, restoresSelection: !restoresSelection + ) } case .restoreAfterEdit: undoManager.registerUndo(withTarget: self) { textView in - textView.restoreSelections(after) - textView.registerSelectionUndo(.restoreBeforeEdit, before: before, after: after) + if restoresSelection { textView.restoreSelections(after) } + textView.registerSelectionUndo( + .restoreBeforeEdit, before: before, after: after, restoresSelection: !restoresSelection + ) } } } diff --git a/mac/zshell/SyntaxHighlightPlugin.swift b/mac/zshell/SyntaxHighlightPlugin.swift index e2fd101..991705c 100644 --- a/mac/zshell/SyntaxHighlightPlugin.swift +++ b/mac/zshell/SyntaxHighlightPlugin.swift @@ -149,17 +149,11 @@ final class SyntaxHighlightCoordinator { self.injectionsData = injectionsData tsLanguage = Language(language: language.parser) - // Weak throughout: this coordinator is reachable from the text view - // (view → plugins → events → coordinator), so any strong capture of - // the view here closes a retain cycle. See `SyntaxHighlightPlugin.setUp`. - tsClient = try! TreeSitterClient(language: tsLanguage) { [weak textView] codePointIndex in - guard let textView, - let location = textView.textContentManager.location(at: codePointIndex), - let position = textView.textContentManager.position(location) - else { - return .zero - } - return Point(row: position.row, column: position.column) + let snapshot = stableTextSnapshot(for: textView) + let lineIndex = UTF16LineIndex(snapshot) + self.lineIndex = lineIndex + tsClient = try! TreeSitterClient(language: tsLanguage) { codePointIndex in + lineIndex.point(at: codePointIndex) } tsClient.invalidationHandler = { [weak self] indexSet in @@ -201,7 +195,7 @@ final class SyntaxHighlightCoordinator { in: documentRange, delta: textView.textContentManager.length, limit: textView.textContentManager.length, - readHandler: Parser.readFunction(for: stableTextSnapshot(for: textView)), + readHandler: Parser.readFunction(for: snapshot), completionHandler: {} ) diff --git a/mac/zshell/TerminalManager.swift b/mac/zshell/TerminalManager.swift index 8337eab..29a8287 100644 --- a/mac/zshell/TerminalManager.swift +++ b/mac/zshell/TerminalManager.swift @@ -48,6 +48,7 @@ struct ClosedSessionRecord: Equatable { let workingDirectory: String /// When the session was closed. let closedAt: Date + var tabGroupID: UUID? = nil } /// Owns the list of projects and the current selection. Each project holds @@ -56,6 +57,7 @@ struct ClosedSessionRecord: Equatable { @MainActor final class TerminalManager: nonisolated ObservableObject { @Published var projects: [Project] = [] + private var isRestoringProjects = false @Published var selectedProjectID: UUID? { willSet { // Diff hosts are expensive WebKit trees. Once a project has put @@ -67,6 +69,12 @@ final class TerminalManager: nonisolated ObservableObject { retainedDiffProjectIDs.insert(selectedProjectID) } } + didSet { + guard !isRestoringProjects, let project = selectedProject, + var group = projectGroup(id: project.groupID), group.isCollapsed else { return } + group.isCollapsed = false + ProjectGroupStore.shared.update(group) + } } @Published var isPanelVisible = false @Published var panelTab: RightPanel = .files @@ -102,6 +110,7 @@ final class TerminalManager: nonisolated ObservableObject { enum TabMoveFailure { case unavailable case containsDiff + case incompatibleLocation case agentAliasConflict(String) var message: String { @@ -110,6 +119,8 @@ final class TerminalManager: nonisolated ObservableObject { return String(localized: "The tab or destination project is no longer available.") case .containsDiff: return String(localized: "Tabs containing diffs can’t be moved between projects.") + case .incompatibleLocation: + return String(localized: "Move this tab to a project with the same local or SSH location, or create a new project from it.") case .agentAliasConflict(let alias): return String( localized: "The destination project already has an agent named “\(alias)”.", @@ -133,6 +144,7 @@ final class TerminalManager: nonisolated ObservableObject { private var translucencyObservation: AnyCancellable? private var accessibilityDisplayObserver: NSObjectProtocol? private var autosaveObservation: AnyCancellable? + private var groupObservation: AnyCancellable? private var terminationObservation: AnyCancellable? private let agentPalette = AgentPaletteController() private var agentPaletteKeyMonitor: Any? @@ -273,6 +285,9 @@ final class TerminalManager: nonisolated ObservableObject { let manager = self assumeMainActor { manager?.refreshTranslucency() } } + groupObservation = ProjectGroupStore.shared.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.objectWillChange.send() } // Every project/tab/selection change re-publishes through the manager, // so a debounced sink snapshots layout after mutations settle without // reading live terminal contents. @@ -334,29 +349,42 @@ final class TerminalManager: nonisolated ObservableObject { /// Creates a project inside `group` and selects it. The group decides /// where the first terminal starts: a plain group opens in the home - /// directory, a folder group in its folder (which also pins the - /// project's directory, anchoring the file tree and git panels). + /// directory and a folder group opens in its folder. Group membership is + /// deliberately separate from a user-pinned project directory, so moving + /// the project later never leaves a hidden directory override behind. @discardableResult func newProject(in group: ProjectGroup) -> Project { let project = makeProject(createInitialSession: false) project.groupID = group.id - if case .folder(let path) = group.kind { - project.customDirectory = path - } project.newSession(directory: group.sessionDirectory) insert(project) return project } - /// Moves `project` into `group` (nil = out of any group). Moving into a - /// folder group pins the project's directory to the group's folder, so - /// the file tree, git panels, and new terminals follow the group; moving - /// out keeps whatever directory the project already had. + /// Moves `project` into `group` (nil = out of any group). Existing tabs, + /// working directories, and an explicit project directory are untouched; + /// the destination group only supplies the default for future sessions. func moveProject(_ project: Project, to group: ProjectGroup?) { + guard projects.contains(where: { $0 === project }), + group == nil || projectGroup(id: group?.id) != nil else { return } project.groupID = group?.id - if let folder = group?.folderPath { - project.customDirectory = folder + if var group, group.isCollapsed { + group.isCollapsed = false + ProjectGroupStore.shared.update(group) + } + } + + /// Deletes a global group without closing its projects. Groups are shared + /// by every Zshell window, so clear membership in every live manager before + /// removing the saved definition; otherwise projects in another window + /// retain a stale id and disappear from both grouped and ungrouped lists. + func deleteProjectGroup(_ group: ProjectGroup) { + for manager in Self.registry { + for project in manager.projects where project.groupID == group.id { + project.groupID = nil + } } + ProjectGroupStore.shared.remove(group) } func promptForSSHProject() { @@ -655,9 +683,24 @@ final class TerminalManager: nonisolated ObservableObject { projects = reorderedProjects } + /// Matches the displayed sidebar sequence, including group placement. + /// Collapsed members stay available to the palette and next/previous + /// navigation, while number shortcuts describe only the visible rows. + var sidebarOrderedProjects: [Project] { + let groups = ProjectGroupStore.shared.groups + let groupIDs = Set(groups.map(\.id)) + return projects.filter { $0.groupID.map { !groupIDs.contains($0) } ?? true } + + groups.flatMap { group in projects.filter { $0.groupID == group.id } } + } + + var visibleSidebarProjects: [Project] { + sidebarOrderedProjects.filter { projectGroup(id: $0.groupID)?.isCollapsed != true } + } + func selectProject(index: Int) { - guard projects.indices.contains(index) else { return } - selectedProjectID = projects[index].id + let visible = visibleSidebarProjects + guard visible.indices.contains(index) else { return } + selectedProjectID = visible[index].id } func selectNextProject() { @@ -669,11 +712,12 @@ final class TerminalManager: nonisolated ObservableObject { } private func shiftProjectSelection(by offset: Int) { - guard !projects.isEmpty, - let current = projects.firstIndex(where: { $0.id == selectedProjectID }) + let ordered = sidebarOrderedProjects + guard !ordered.isEmpty, + let current = ordered.firstIndex(where: { $0.id == selectedProjectID }) else { return } - let next = (current + offset + projects.count) % projects.count - selectedProjectID = projects[next].id + let next = (current + offset + ordered.count) % ordered.count + selectedProjectID = ordered[next].id } // MARK: - Sessions @@ -727,10 +771,14 @@ final class TerminalManager: nonisolated ObservableObject { if let project = record.projectID.flatMap({ projectID in projects.first { $0.id == projectID } }) ?? selectedProject { + selectedProjectID = project.id project.newSession( directory: record.workingDirectory, tabTitle: record.customTitle ) + if let tab = project.selectedTab { + project.moveTab(tab.id, toGroup: project.tabGroup(id: record.tabGroupID)?.id) + } } else { // No project left in this window: start one anchored at the // recorded directory. The tab title is only carried when a @@ -789,7 +837,8 @@ final class TerminalManager: nonisolated ObservableObject { projectID: project.id, title: project.name, windowTitle: manager === self ? nil : manager.window?.title, - isEnabled: tab.diffs.isEmpty && aliases.isDisjoint(with: destinationAliases) + isEnabled: tab.diffs.isEmpty && source.location == project.location + && aliases.isDisjoint(with: destinationAliases) ) } } @@ -819,6 +868,9 @@ final class TerminalManager: nonisolated ObservableObject { guard tab.diffs.isEmpty else { return TabMoveResult(failure: .containsDiff) } + guard source.location == destination.location else { + return TabMoveResult(failure: .incompatibleLocation) + } let destinationAliases = Set(destination.sessions.compactMap { $0.agentStatus?.alias }) if let conflict = tab.sessions.compactMap({ $0.agentStatus?.alias }) .first(where: destinationAliases.contains) { @@ -835,6 +887,38 @@ final class TerminalManager: nonisolated ObservableObject { return TabMoveResult(failure: nil) } + /// Pulls a live tab out into a newly created project. Passing a group puts + /// that project under its sidebar section; nil creates an ungrouped + /// project. The tab is adopted without recreating its PTYs or pane tree. + /// If validation fails, no placeholder project is left behind. + @discardableResult + func moveTabToNewProject( + id tabID: UUID, + from sourceProjectID: UUID, + in group: ProjectGroup? + ) -> TabMoveResult { + guard let source = projects.first(where: { $0.id == sourceProjectID }), + let tab = source.tabs.first(where: { $0.id == tabID }) + else { return TabMoveResult(failure: .unavailable) } + + guard tab.diffs.isEmpty else { + return TabMoveResult(failure: .containsDiff) + } + + let destination = makeProject(location: source.location, createInitialSession: false) + destination.launchSettings = source.launchSettings + destination.customDirectory = source.customDirectory + destination.finishRemoteConnectionProbe(source.remoteConnectionState) + destination.groupID = group?.id + insert(destination) + guard let moved = source.detachTabForTransfer(id: tabID) else { + remove(destination) + return TabMoveResult(failure: .unavailable) + } + destination.adoptTransferredTab(moved, manager: self) + return TabMoveResult(failure: nil) + } + /// Brings `session` to the foreground: selects its project and tab, then /// focuses its pane. Backs the command palette's session switcher; a no-op /// if the session is no longer open anywhere. @@ -973,7 +1057,7 @@ final class TerminalManager: nonisolated ObservableObject { ) { guard case .session(let session)? = selectedProject?.focusedContent else { return } session.sendCommand(preset.command) - if appendingReturn { session.sendCommand("\r") } + if appendingReturn { session.sendEnter() } } /// Whether ⌘K has a terminal on screen to act on right now. @@ -1418,8 +1502,7 @@ final class TerminalManager: nonisolated ObservableObject { typealias ProjectSnapshot = SessionSnapshot.ProjectSnapshot var histories: [String: String] = [:] let snapshot = SessionSnapshot( - projects: projects.compactMap { project in - guard !project.tabs.isEmpty else { return nil } + projects: projects.map { project in let projectSessions = project.sessions let tabs = project.tabs.map { tab -> ProjectSnapshot.TabSnapshot in let layout = Self.layoutSnapshot( @@ -1436,6 +1519,7 @@ final class TerminalManager: nonisolated ObservableObject { customName: tab.customName, isPinned: tab.isPinned, markerColorHex: tab.markerColor?.hex, + tabGroupID: tab.tabGroupID, launchSettingsOverride: tab.launchSettingsOverride, contextSessionIndex: tab.contextSession.flatMap { context in projectSessions.firstIndex { $0.id == context.id } @@ -1448,6 +1532,7 @@ final class TerminalManager: nonisolated ObservableObject { markerColorHex: project.markerColor?.hex, customDirectory: project.customDirectory, groupID: project.groupID, + tabGroups: project.tabGroups, launchSettings: project.launchSettings, location: project.location, tabs: tabs, @@ -1534,10 +1619,13 @@ final class TerminalManager: nonisolated ObservableObject { /// false when the snapshot holds nothing restorable. Sidebar state is /// applied even then — the window claimed this snapshot's layout. private func restore(from snapshot: SessionSnapshot) -> Bool { + // A restored selection must not undo the saved collapsed sidebar state. + isRestoringProjects = true + defer { isRestoringProjects = false } if let visible = snapshot.isLeftSidebarVisible { isLeftSidebarVisible = visible } if let visible = snapshot.isRightPanelVisible { isPanelVisible = visible } if let tab = snapshot.rightPanelTab { panelTab = tab } - for saved in snapshot.projects where !saved.tabs.isEmpty { + for saved in snapshot.projects { let project = makeProject( location: saved.location ?? .local, isPinned: saved.isPinned, @@ -1546,8 +1634,14 @@ final class TerminalManager: nonisolated ObservableObject { project.customName = Project.normalizedCustomName(saved.customName) project.markerColor = saved.markerColorHex.flatMap(ProjectTabMarkerColor.init(hex:)) project.customDirectory = saved.customDirectory - project.groupID = saved.groupID + // A group may have been deleted while this window was closed or + // by another live window. Treat an orphaned id as ungrouped so the + // restored project never disappears from the sidebar. + project.groupID = ProjectGroupStore.shared.group(id: saved.groupID) == nil + ? nil + : saved.groupID project.launchSettings = saved.launchSettings + project.beginRestoringTabGroups(saved.tabGroups) var restoredContexts: [(tab: PaneTab, sessionIndex: Int)] = [] for savedTab in saved.tabs { guard let tab = project.restoreTab( @@ -1562,13 +1656,10 @@ final class TerminalManager: nonisolated ObservableObject { where restoredSessions.indices.contains(context.sessionIndex) { context.tab.contextSession = restoredSessions[context.sessionIndex] } - guard !project.tabs.isEmpty else { - projectObservations[project.id] = nil - continue - } if let index = saved.selectedTabIndex, project.tabs.indices.contains(index) { project.selectedTabID = project.tabs[index].id } + project.finishRestoringTabGroups() project.resetRecency() projects.append(project) } diff --git a/mac/zshell/TerminalPromptQueue.swift b/mac/zshell/TerminalPromptQueue.swift index 53d64ad..2c42114 100644 --- a/mac/zshell/TerminalPromptQueue.swift +++ b/mac/zshell/TerminalPromptQueue.swift @@ -113,12 +113,10 @@ extension TerminalSession { /// fresh prompt. Returns whether the head was sent. private func sendPromptQueueHeadIfShellIsReady() -> Bool { guard !promptQueue.commands.isEmpty, - terminalPromptSelectionIsReady + terminalPromptQueueIsReady else { return false } // The gate above already proves the root shell is the foreground - // process — nothing else can be interrupted by this send — and that - // ZLE just initialized a line the user has not typed into yet, so - // the fill lands on an empty command line. + // process and its last redraw had no draft input to append to. guard let head = promptQueue.commands.first else { return false } promptQueue.remove(at: 0) sendQueuedPrompt(head) @@ -131,6 +129,6 @@ extension TerminalSession { /// automation router. No backend is ever addressed privately. func sendQueuedPrompt(_ command: String) { sendCommand(command) - sendCommand("\r") + sendEnter() } } diff --git a/mac/zshell/TerminalSession.swift b/mac/zshell/TerminalSession.swift index d6ae5f7..e87fcb2 100644 --- a/mac/zshell/TerminalSession.swift +++ b/mac/zshell/TerminalSession.swift @@ -520,6 +520,7 @@ final class TerminalSession: NSObject, nonisolated ObservableObject, nonisolated attributes: [.posixPermissions: 0o700] ) let selectionStatePath = shellQuote(directory.appendingPathComponent("prompt-selection.pid").path) + let promptQueueStatePath = shellQuote(directory.appendingPathComponent("prompt-queue.pid").path) var files = [ ".zshenv": """ [[ -r \"$ZSHELL_ORIGINAL_ZDOTDIR/.zshenv\" ]] && source \"$ZSHELL_ORIGINAL_ZDOTDIR/.zshenv\" @@ -546,6 +547,17 @@ final class TerminalSession: NSObject, nonisolated ObservableObject, nonisolated builtin print -n $'\\e]133;A;cl=line\\a' } _zshell_selection_active=0 + _zshell_prompt_queue_empty=0 + _zshell_prompt_queue_ready() { + local _zshell_queue_now_empty=$(( ${#BUFFER} == 0 )) + (( _zshell_queue_now_empty == _zshell_prompt_queue_empty )) && return + _zshell_prompt_queue_empty=$_zshell_queue_now_empty + if (( _zshell_queue_now_empty )); then + builtin print -r -- "$$" >| \(promptQueueStatePath) + else + builtin print -rn -- '' >| \(promptQueueStatePath) + fi + } _zshell_begin_selection() { MARK=$CURSOR REGION_ACTIVE=1 @@ -624,17 +636,21 @@ final class TerminalSession: NSObject, nonisolated ObservableObject, nonisolated } _zshell_selection_finished() { builtin print -rn -- '' >| \(selectionStatePath) + builtin print -rn -- '' >| \(promptQueueStatePath) + _zshell_prompt_queue_empty=0 } add-zsh-hook precmd _zshell_prompt_marker _zshell_line_init() { _zshell_selection_active=0 REGION_ACTIVE=0 _zshell_selection_ready + _zshell_prompt_queue_ready builtin print -n $'\\e]133;P;k=i\\a\\e]133;B\\a' } add-zle-hook-widget line-init _zshell_line_init add-zle-hook-widget line-finish _zshell_selection_finished add-zle-hook-widget keymap-select _zshell_selection_ready + add-zle-hook-widget line-pre-redraw _zshell_prompt_queue_ready _zshell_insert_newline() { LBUFFER+=$'\\n'; } zle -N _zshell_insert_newline for _zshell_keymap in emacs viins; do @@ -799,6 +815,17 @@ extension TerminalSession: TerminalBackendEvents { return readyPID > 0 && readyPID == foregroundPID } + var terminalPromptQueueIsReady: Bool { + guard terminalPromptSelectionIsReady, + let launchDirectoryURL, let foregroundPID = surface.foregroundPid, + let value = try? String( + contentsOf: launchDirectoryURL.appendingPathComponent("prompt-queue.pid"), + encoding: .utf8 + ) + else { return false } + return value.trimmingCharacters(in: .whitespacesAndNewlines) == String(foregroundPID) + } + func terminalDidChangeTitle(_ title: String) { guard !title.isEmpty else { return } self.title = title @@ -904,13 +931,17 @@ extension TerminalSession: TerminalBackendEvents { if let fileURL = existingFileURL(from: value) { return .file(fileURL) } + // URL treats a bare host followed by a port as a custom scheme. + if let url = Self.bareWebURL(from: value) { + return .url(url) + } if let url = URL(string: value), url.scheme != nil, !url.isFileURL { return .url(url) } - return Self.bareWebURL(from: value).map { .url($0) } + return nil } /// Characters that end a sentence around a pasted or printed URL. The @@ -948,8 +979,9 @@ extension TerminalSession: TerminalBackendEvents { match.range.length == candidate.utf16.count, !candidate.isEmpty else { return nil } - let scheme = candidate.lowercased().hasPrefix("localhost") ? "http" : "https" - return URL(string: "\(scheme)://\(candidate)") + guard var components = URLComponents(string: "https://\(candidate)") else { return nil } + if components.host?.lowercased() == "localhost" { components.scheme = "http" } + return components.url } /// Resolves terminal links the way the shell would: `file:` URLs are @@ -959,8 +991,7 @@ extension TerminalSession: TerminalBackendEvents { /// numeric locations off. private func existingFileURL(from value: String) -> URL? { let candidate: URL - if let url = URL(string: value), url.scheme != nil { - guard url.isFileURL else { return nil } + if let url = URL(string: value), url.isFileURL { candidate = url } else { let decoded = value.removingPercentEncoding ?? value diff --git a/mac/zshell/WorkspaceChromeViews.swift b/mac/zshell/WorkspaceChromeViews.swift new file mode 100644 index 0000000..ce6d77c --- /dev/null +++ b/mac/zshell/WorkspaceChromeViews.swift @@ -0,0 +1,452 @@ +// +// WorkspaceChromeViews.swift +// zshell +// + +import AppKit + +/// Small native controls shared by the project sidebar and session strip. +/// Colors are resolved when drawn, including live appearance/theme changes. +final class WorkspaceChromeButton: NSButton { + var onAction: (() -> Void)? + private var isHovered = false + + init(symbol: String, label: String, action: (() -> Void)? = nil) { + super.init(frame: .zero) + isBordered = false + imagePosition = .imageOnly + imageScaling = .scaleProportionallyDown + setButtonType(.momentaryChange) + target = self + self.action = #selector(invokeAction) + onAction = action + configure(symbol: symbol, label: label) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(symbol: String, label: String, pointSize: CGFloat = 12) { + image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: pointSize, weight: .medium)) + contentTintColor = .secondaryLabelColor + toolTip = label + setAccessibilityLabel(label) + } + + func configure(symbol: String, command: AppCommand, pointSize: CGFloat = 12) { + let shortcut = AppSettings.shared.commandShortcut(for: command).displayString + configure(symbol: symbol, label: "\(command.title) (\(shortcut))", pointSize: pointSize) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea( + rect: .zero, + options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], + owner: self + )) + } + + override func mouseEntered(with event: NSEvent) { isHovered = true; needsDisplay = true } + override func mouseExited(with event: NSEvent) { isHovered = false; needsDisplay = true } + + override func draw(_ dirtyRect: NSRect) { + if isHovered || isHighlighted { + NSColor.labelColor.withAlphaComponent(isHighlighted ? 0.12 : 0.07).setFill() + NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 5, yRadius: 5).fill() + } + super.draw(dirtyRect) + } + + @objc private func invokeAction() { onAction?() } +} + +final class WorkspaceWindowDragView: NSView { + override func mouseDown(with event: NSEvent) { + if event.clickCount == 2 { + window?.performTitlebarDoubleClickAction() + } else { + window?.performDrag(with: event) + } + } +} + +extension NSView { + /// PaneLayoutView reports SwiftUI global coordinates with a top-left + /// origin. Use that same content coordinate space for native drag targets. + func workspaceGlobalRect(_ rect: NSRect) -> NSRect { + guard let root = window?.contentView else { return .zero } + var converted = convert(rect, to: root) + if !root.isFlipped { converted.origin.y = root.bounds.height - converted.maxY } + return converted + } + + func workspaceGlobalPoint(_ event: NSEvent) -> NSPoint { + workspaceGlobalRect(NSRect(origin: convert(event.locationInWindow, from: nil), size: .zero)).origin + } +} + +/// Stable row views avoid replacing live field editors when terminal titles +/// change. Terminal surfaces are never owned by this chrome. +final class WorkspaceItemView: NSView, NSTextFieldDelegate { + let titleLabel = NSTextField(labelWithString: "") + private let subtitleLabel = NSTextField(labelWithString: "") + private let iconView = NSImageView() + private let disclosureView = NSImageView() + private let markerView = NSImageView() + private let pinView = NSImageView() + private let countLabel = NSTextField(labelWithString: "") + private let shortcutLabel = NSTextField(labelWithString: "") + private let badge = AgentStatusBadgeView(frame: .zero) + private let actionButton = WorkspaceChromeButton(symbol: "xmark", label: String(localized: "Close")) + private let renameField = NSTextField() + private let menuPresenter = AppKitContextMenuMonitorView() + + var onSelect: (() -> Void)? + var onRename: (() -> Void)? + var onDrag: ((NSEvent) -> Void)? + var onDragEnded: ((NSEvent) -> Void)? + var onDragCancelled: (() -> Void)? + var onNavigate: ((UInt16) -> Void)? + var menuItems: (() -> [AppKitContextMenuItem])? + private var renameCommit: ((String) -> Void)? + private weak var renamePreviousResponder: NSResponder? + private var mouseOrigin: NSPoint? + private var hasDragged = false + private var dragCancelMonitor: Any? + private var isHovered = false + private var isSelected = false + private var isGroup = false + private var isGrouped = false + private var isDirty = false + private var isSidebar = false + private var indent: CGFloat = 0 + private var scale: CGFloat = 1 + private var badgeWidth: CGFloat = 0 + private var titleWidth: CGFloat = 0 + private var countWidth: CGFloat = 0 + private var hasAction = false + var isDropTarget = false { didSet { if oldValue != isDropTarget { needsDisplay = true } } } + var isRenaming: Bool { renameCommit != nil } + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + for label in [titleLabel, subtitleLabel, countLabel, shortcutLabel] { + label.translatesAutoresizingMaskIntoConstraints = true + label.lineBreakMode = .byTruncatingMiddle + label.maximumNumberOfLines = 1 + label.isSelectable = false + } + for image in [iconView, disclosureView, markerView, pinView] { + image.imageScaling = .scaleProportionallyDown + image.setAccessibilityElement(false) + } + badge.translatesAutoresizingMaskIntoConstraints = true + renameField.delegate = self + renameField.isHidden = true + renameField.isBordered = false + renameField.drawsBackground = false + renameField.focusRingType = .exterior + for view in [disclosureView, iconView, markerView, pinView, titleLabel, subtitleLabel, + countLabel, shortcutLabel, badge, actionButton, renameField] { + addSubview(view) + } + setAccessibilityElement(true) + setAccessibilityRole(.button) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func apply( + title: String, subtitle: String? = nil, icon: NSImage?, + selected: Bool, group: Bool = false, collapsed: Bool = false, + grouped: Bool = false, pinned: Bool = false, marker: ProjectTabMarkerColor? = nil, + count: Int? = nil, rollup: ZshellAgentRollup? = nil, dirty: Bool = false, + sidebar: Bool = false, indent: CGFloat = 0, scale: CGFloat = 1, + shortcut: String? = nil, actionSymbol: String = "xmark", + actionLabel: String = String(localized: "Close"), action: (() -> Void)? = nil + ) { + self.isSelected = selected + self.isGroup = group + self.isGrouped = grouped + self.isDirty = dirty + self.isSidebar = sidebar + self.indent = indent + self.scale = scale + self.hasAction = action != nil + let fontSize: CGFloat = (group ? 10.5 : 11.5) * scale + titleLabel.font = .systemFont(ofSize: fontSize, weight: group ? .medium : .regular) + titleLabel.stringValue = title + titleLabel.textColor = selected ? .labelColor : .secondaryLabelColor + titleWidth = ceil(titleLabel.attributedStringValue.size().width) + titleLabel.isHidden = isRenaming + subtitleLabel.font = .systemFont(ofSize: 10 * scale) + subtitleLabel.textColor = .secondaryLabelColor + subtitleLabel.stringValue = subtitle ?? "" + subtitleLabel.isHidden = subtitle?.isEmpty != false + iconView.image = icon + iconView.contentTintColor = icon?.isTemplate == true ? (selected ? Theme.accent : .secondaryLabelColor) : nil + disclosureView.isHidden = !group + disclosureView.image = NSImage(systemSymbolName: collapsed ? "chevron.right" : "chevron.down", accessibilityDescription: nil) + disclosureView.contentTintColor = .secondaryLabelColor + pinView.isHidden = !pinned + pinView.image = NSImage(systemSymbolName: "pin.fill", accessibilityDescription: nil) + pinView.contentTintColor = .secondaryLabelColor + markerView.isHidden = marker == nil + markerView.image = NSImage(systemSymbolName: "tag.fill", accessibilityDescription: nil) + markerView.contentTintColor = marker?.nsColor + countLabel.font = .monospacedDigitSystemFont(ofSize: 9 * scale, weight: .medium) + countLabel.textColor = .secondaryLabelColor + countLabel.stringValue = count.map(String.init) ?? "" + countLabel.isHidden = count == nil + countWidth = count == nil ? 0 : ceil(countLabel.attributedStringValue.size().width) + 6 * scale + shortcutLabel.font = .systemFont(ofSize: 10 * scale) + shortcutLabel.textColor = .secondaryLabelColor + shortcutLabel.stringValue = shortcut ?? "" + actionButton.configure(symbol: actionSymbol, label: actionLabel, pointSize: 9 * scale) + actionButton.onAction = action + if let rollup { + badge.apply(phase: rollup.phase, count: rollup.count) + badgeWidth = badge.intrinsicContentSize.width + 4 * scale + badge.isHidden = false + } else { + badgeWidth = 0 + badge.isHidden = true + } + renameField.font = titleLabel.font + renameField.textColor = .labelColor + setAccessibilityLabel(subtitle.map { "\(title), \($0)" } ?? title) + let state = group + ? String(localized: collapsed ? "Collapsed" : "Expanded") + : (selected ? String(localized: "Selected") : "") + let markerDescription = marker.map { String(localized: "Color marker \($0.displayValue)") } + setAccessibilityValue([state, markerDescription].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ")) + updateActionVisibility() + needsLayout = true + needsDisplay = true + } + + var preferredWidth: CGFloat { + let leading = 9 * scale + indent + (isGroup ? 13 * scale : 0) + + (iconView.image == nil ? 0 : 17 * scale) + + (markerView.isHidden ? 0 : 13 * scale) + (pinView.isHidden ? 0 : 13 * scale) + let trailing = 6 * scale + actionSlotWidth + countWidth + badgeWidth + return min(260 * scale, max(68 * scale, leading + min(titleWidth, 160 * scale) + trailing)) + } + + private var actionSlotWidth: CGFloat { hasAction || !shortcutLabel.stringValue.isEmpty ? 24 * max(1, scale) : 0 } + + override func layout() { + super.layout() + var x = 8 * scale + indent + let iconSize = min(14 * scale, bounds.height - 8) + for view in [disclosureView, iconView, markerView, pinView] where !view.isHidden { + guard view !== iconView || iconView.image != nil else { continue } + let width = view === disclosureView ? 10 * scale : iconSize + view.frame = NSRect(x: x, y: (bounds.height - iconSize) / 2, width: width, height: iconSize) + x += width + 4 * scale + } + let right = max(x, bounds.width - 5 * scale - actionSlotWidth) + actionButton.frame = NSRect(x: right, y: (bounds.height - 24 * max(1, scale)) / 2, + width: actionSlotWidth, height: 24 * max(1, scale)) + shortcutLabel.frame = NSRect(x: right, y: (bounds.height - 16 * scale) / 2, + width: actionSlotWidth, height: 16 * scale) + let visibleBadgeWidth = bounds.width - x - actionSlotWidth > 80 * scale ? badgeWidth : 0 + badge.isHidden = visibleBadgeWidth == 0 + badge.frame = NSRect(x: right - visibleBadgeWidth, y: (bounds.height - 15) / 2, + width: max(0, visibleBadgeWidth - 4 * scale), height: 15) + countLabel.frame = NSRect(x: right - visibleBadgeWidth - countWidth, + y: (bounds.height - 14 * scale) / 2, width: countWidth, height: 14 * scale) + let titleSpace = max(0, right - visibleBadgeWidth - countWidth - x - 3 * scale) + let titleHeight = ceil((titleLabel.font?.ascender ?? 12) - (titleLabel.font?.descender ?? -3)) + 2 + let subtitleHeight = subtitleLabel.isHidden ? 0 : 13 * scale + let titleY = (bounds.height - titleHeight - subtitleHeight) / 2 + titleLabel.frame = NSRect(x: x, y: titleY, width: titleSpace, height: titleHeight) + subtitleLabel.frame = NSRect(x: x, y: titleY + titleHeight, width: titleSpace, height: subtitleHeight) + renameField.frame = titleLabel.frame + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard let hit = super.hitTest(point) else { return nil } + if hit === actionButton || hit === renameField || hit.isDescendant(of: renameField) { return hit } + return self + } + + override func draw(_ dirtyRect: NSRect) { + let shape = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 1), xRadius: 6, yRadius: 6) + if isDropTarget || isSelected || isHovered || isGroup { + (isDropTarget ? Theme.accent.withAlphaComponent(0.15) + : NSColor.labelColor.withAlphaComponent(isSelected ? 0.09 : (isHovered ? 0.05 : 0.025))).setFill() + shape.fill() + } + if isDropTarget { + Theme.accent.setStroke() + shape.lineWidth = 1 + shape.stroke() + } + if isGrouped && !isSidebar { + Theme.accent.withAlphaComponent(0.45).setFill() + NSRect(x: 2, y: bounds.maxY - 2, width: max(0, bounds.width - 4), height: 1).fill() + } + if isDirty && actionButton.isHidden { + NSColor.secondaryLabelColor.setFill() + NSBezierPath(ovalIn: NSRect(x: actionButton.frame.midX - 2.5, y: bounds.midY - 2.5, width: 5, height: 5)).fill() + } + if window?.firstResponder === self { + NSColor.keyboardFocusIndicatorColor.setStroke() + shape.lineWidth = 2 + shape.stroke() + } + } + + override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance(); needsDisplay = true } + override func becomeFirstResponder() -> Bool { needsDisplay = true; return true } + override func resignFirstResponder() -> Bool { needsDisplay = true; return true } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea(rect: .zero, + options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], owner: self)) + } + + override func mouseEntered(with event: NSEvent) { isHovered = true; updateActionVisibility(); needsDisplay = true } + override func mouseExited(with event: NSEvent) { isHovered = false; updateActionVisibility(); needsDisplay = true } + + private func updateActionVisibility() { + actionButton.isHidden = !hasAction || isRenaming || (!isHovered && !isGroup) + shortcutLabel.isHidden = isRenaming || !actionButton.isHidden || isDirty + } + + override func mouseDown(with event: NSEvent) { + if event.modifierFlags.contains(.control) { rightMouseDown(with: event); return } + mouseOrigin = event.locationInWindow + hasDragged = false + if event.clickCount == 2 { mouseOrigin = nil; onRename?() } + } + + override func mouseDragged(with event: NSEvent) { + guard !isRenaming, let mouseOrigin else { return } + if !hasDragged { + guard hypot(event.locationInWindow.x - mouseOrigin.x, event.locationInWindow.y - mouseOrigin.y) >= 4 else { return } + hasDragged = true + dragCancelMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + let input = WorkspaceChromeEvent(event) + let output = MainActor.assumeIsolated { + guard let self, let event = input.value, event.window === self.window, event.keyCode == 53 else { return input } + self.cancelMouseDrag() + return WorkspaceChromeEvent(nil) + } + return output.value + } + } + NSCursor.closedHand.set() + onDrag?(event) + } + + override func mouseUp(with event: NSEvent) { + guard mouseOrigin != nil else { return } + mouseOrigin = nil + removeDragMonitor() + if hasDragged { onDragEnded?(event) } + else if bounds.contains(convert(event.locationInWindow, from: nil)) { onSelect?() } + hasDragged = false + NSCursor.arrow.set() + } + + override func otherMouseUp(with event: NSEvent) { + if event.buttonNumber == 2, !isGroup { actionButton.onAction?() } + else { super.otherMouseUp(with: event) } + } + + override func rightMouseDown(with event: NSEvent) { + guard let items = menuItems?(), !items.isEmpty else { return } + menuPresenter.popUp(items: items, at: convert(event.locationInWindow, from: nil), in: self) + } + + override func accessibilityPerformShowMenu() -> Bool { + guard let items = menuItems?(), !items.isEmpty else { return false } + menuPresenter.popUp(items: items, at: NSPoint(x: bounds.midX, y: bounds.midY), in: self) + return true + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 109, event.modifierFlags.contains(.shift) { + _ = accessibilityPerformShowMenu() + return + } + switch event.keyCode { + case 36, 49: onSelect?() + case 120: onRename?() + case 53: + cancelMouseDrag() + case 123, 124, 125, 126: onNavigate?(event.keyCode) + default: super.keyDown(with: event) + } + } + + override func accessibilityPerformPress() -> Bool { onSelect?(); return onSelect != nil } + + private func cancelMouseDrag() { + mouseOrigin = nil + hasDragged = false + removeDragMonitor() + onDragCancelled?() + NSCursor.arrow.set() + } + + private func removeDragMonitor() { + if let dragCancelMonitor { NSEvent.removeMonitor(dragCancelMonitor) } + dragCancelMonitor = nil + } + + deinit { + if let dragCancelMonitor { NSEvent.removeMonitor(dragCancelMonitor) } + } + + func beginRename(value: String, commit: @escaping (String) -> Void) { + guard !isRenaming else { return } + renamePreviousResponder = window?.firstResponder + renameCommit = commit + renameField.stringValue = value + renameField.setAccessibilityLabel(String(localized: "Name")) + renameField.isHidden = false + titleLabel.isHidden = true + updateActionVisibility() + layoutSubtreeIfNeeded() + window?.makeFirstResponder(renameField) + renameField.selectText(nil) + } + + private func finishRename(apply: Bool, restoreFocus: Bool = false) { + guard let commit = renameCommit else { return } + let name = renameField.stringValue + renameCommit = nil + let responder = renamePreviousResponder + renamePreviousResponder = nil + if restoreFocus { window?.makeFirstResponder(nil) } + renameField.isHidden = true + titleLabel.isHidden = false + updateActionVisibility() + if apply { commit(name) } + if restoreFocus, let responder = responder as? NSView, responder.window === window { + window?.makeFirstResponder(responder) + } + } + + func controlTextDidEndEditing(_ notification: Notification) { finishRename(apply: true) } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { finishRename(apply: true, restoreFocus: true); return true } + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { finishRename(apply: false, restoreFocus: true); return true } + return false + } +} + +private struct WorkspaceChromeEvent: @unchecked Sendable { + let value: NSEvent? + init(_ value: NSEvent?) { self.value = value } +} diff --git a/mac/zshell/ZshellAutomationProtocol.swift b/mac/zshell/ZshellAutomationProtocol.swift index cc11c95..7460a80 100644 --- a/mac/zshell/ZshellAutomationProtocol.swift +++ b/mac/zshell/ZshellAutomationProtocol.swift @@ -62,11 +62,8 @@ enum ZshellJSONValue: Codable, Equatable, Sendable { } var intValue: Int? { - guard case .number(let value) = self, - value.rounded() == value, - value >= Double(Int.min), value <= Double(Int.max) - else { return nil } - return Int(value) + guard case .number(let value) = self else { return nil } + return Int(exactly: value) } var arrayValue: [ZshellJSONValue]? { diff --git a/mac/zshell/ZshellTerminalView.swift b/mac/zshell/ZshellTerminalView.swift index b626849..ddb7591 100644 --- a/mac/zshell/ZshellTerminalView.swift +++ b/mac/zshell/ZshellTerminalView.swift @@ -509,8 +509,12 @@ final class ZshellTerminalView: AppTerminalView, TerminalBackendSurface { } private func linkTarget(for event: NSEvent) -> TerminalLinkTarget? { - guard event.modifierFlags.contains(.command), let hoveredLink else { return nil } - return events?.terminalLinkTarget(for: hoveredLink) + guard event.modifierFlags.contains(.command) else { return nil } + let text = contextText(for: event) + if let hoveredLink, let target = events?.terminalLinkTarget(for: hoveredLink) { + return target + } + return text.flatMap { events?.terminalLinkTarget(for: $0) } } private func contextMenu(linkTarget: TerminalLinkTarget?) -> NSMenu { diff --git a/mac/zshell/zshellApp.swift b/mac/zshell/zshellApp.swift index 11cf625..74dc329 100644 --- a/mac/zshell/zshellApp.swift +++ b/mac/zshell/zshellApp.swift @@ -301,7 +301,7 @@ private struct ZshellCommands: Commands { Divider() - ForEach(Array((manager?.projects ?? []).prefix(9).enumerated()), id: \.element.id) { index, project in + ForEach(Array((manager?.visibleSidebarProjects ?? []).prefix(9).enumerated()), id: \.element.id) { index, project in Button(project.name) { manager?.selectProject(index: index) } diff --git a/web/content/docs/projects.mdx b/web/content/docs/projects.mdx index 7b0b2b2..e124ff8 100644 --- a/web/content/docs/projects.mdx +++ b/web/content/docs/projects.mdx @@ -33,6 +33,24 @@ same way a tab title does. Right-click it in the sidebar for **Rename…** (and Project**. Closing a project ends its sessions; it doesn't touch anything on disk. +### Sidebar groups + +Use **New Group** at the bottom of the sidebar to create a **Plain Group** or a +**Folder Group…**. Plain groups start new terminals in your home directory; +folder groups supply the selected folder as the default. An explicit project +directory still takes priority. Moving a project between groups does not change +its running terminals or replace its pinned directory. + +Drag a project onto a group header or another project to organize it. Drag it +onto **New Ungrouped Project** to remove its group membership. Group headers +can also be dragged to reorder groups. Click a header to collapse it, +double-click to rename it, or use its context menu. **Remove Group** keeps all +of its projects open. Number shortcuts follow the currently visible project +rows; next/previous project navigation can reveal members of a collapsed group. + +You can drop folders from Finder anywhere in the sidebar's project list. An +already open folder is focused instead of creating a duplicate project. + ### The project directory Two panels need to know which folder they're showing: the file tree and the git @@ -72,9 +90,31 @@ is set to — with its own working directory and scrollback. | Next / previous tab | Cmd+Shift+] / [ | | Close the focused pane | Cmd+W | -A new session starts in the pinned project directory when one is set. Otherwise -it inherits the current session's directory, then falls back to your home -directory when neither is known. +A new local session starts in the pinned project directory when one is set, +then uses its sidebar group's default directory. Without either, it inherits +the current session's directory and falls back to your home directory. + +### Tab groups + +Use **New Tab Group** beside the new-session button, or right-click a tab and +choose **Move to Group → New Tab Group**. The current tab joins the new group, +whose name can be edited immediately. New sessions opened from a grouped tab +stay in that group; the group's **+** button creates a session directly inside it. + +Drag a tab onto a group header or another tab to move it. Its context menu also +lists groups and **Remove from Group**. Click a group header to collapse or +expand it, and double-click to rename it. Collapsing a group keeps the active +terminal running and visible; switching to a hidden member expands the group. +Removing a group keeps its tabs and sessions open. Pinning a grouped tab moves +it to the fixed section outside the groups. + +A tab can also be dropped onto a sidebar project, or onto a sidebar group header +to create a new project inside that group. **New Ungrouped Project** creates a +project outside all sidebar groups. **Move Tab to Project** in the tab menu +also reaches other windows. These moves preserve the running terminals and +pane layout. Projects must have the same local or SSH location; creating a new +project from a tab preserves its source location. Tabs containing diffs stay in +their current project. ### Tab titles @@ -99,7 +139,9 @@ restored separately. Zshell snapshots your layout as you work, so quitting and reopening gives you back: - every project, in order -- every tab, including custom names and pinned directories +- every tab, including custom names, pinning, and color markers +- sidebar groups and tab groups, including names, order, membership, and collapse state +- pinned project directories and empty projects - the pane layout inside each tab - which sidebars were open and which right panel was selected diff --git a/web/content/docs/projects.zh.mdx b/web/content/docs/projects.zh.mdx index 58a2386..ef6f253 100644 --- a/web/content/docs/projects.zh.mdx +++ b/web/content/docs/projects.zh.mdx @@ -24,6 +24,14 @@ description: Zshell 如何组织你的工作——侧边栏里的项目、作为 项目的名字来自它当前选中的会话——也就是说,它跟着终端走,和标签页标题一样。右键侧边栏里的项目,可以 **Rename…**(**Use Automatic Title** 撤销)、**Set Project Directory…** 和 **Close Project**。关闭项目会结束它的会话,但不会动磁盘上的任何东西。 +### 侧边栏分组 + +点击侧边栏底部的 **新建分组**,可以创建普通分组或文件夹分组。普通分组中新建的终端从用户主目录启动;文件夹分组提供选定的默认目录。手动固定的项目目录始终优先。移动项目到其他分组,不会改变正在运行的终端,也不会覆盖已固定的目录。 + +把项目拖到分组标题或另一个项目上即可归组,拖到 **新建未分组项目** 区域可以移出分组。分组标题之间也可以拖拽排序。单击标题折叠或展开,双击重命名,右键打开分组菜单。**移除分组** 会保留其中所有项目。数字快捷键按当前可见项目的顺序排列;上一项、下一项导航可以切到折叠组内的项目,并自动展开该组。 + +也可以从 Finder 把文件夹拖到侧栏项目列表。已打开的文件夹会被选中,不会重复创建项目。 + ### 项目目录 有两个面板需要知道自己在显示哪个文件夹:文件树和 git 面板。默认情况下 Zshell 自己判断—— @@ -54,7 +62,15 @@ Directory** 则交还自动判断。 | 下一个 / 上一个标签页 | Cmd+Shift+] / [ | | 关闭当前窗格 | Cmd+W | -如果固定了项目目录,新会话优先从那里启动。否则它继承当前会话的目录;两个都没有时,再回退到用户主目录。 +本地新会话优先从固定的项目目录启动,其次使用侧栏分组的默认目录。没有这两项时,它继承当前会话的目录,再回退到用户主目录。 + +### 标签页分组 + +点击新建会话按钮旁的 **新建标签页分组**,或右键标签页选择 **移动到分组 → 新建标签页分组**。当前标签会加入新组,并立即进入名称编辑。选中组内标签后新建的会话会留在该组;组标题上的 **+** 可以直接在组内创建会话。 + +把标签拖到组标题或另一个标签上即可移动。右键菜单也可以选择目标分组,或 **移出分组**。单击组标题折叠、展开,双击重命名。折叠时当前终端仍会运行并继续显示;切换到隐藏的标签会自动展开其分组。移除分组会保留所有标签与会话。固定组内标签时,该标签会移到分组之外的固定区。 + +标签还可以拖到侧栏已有项目,或拖到侧栏分组标题,在该分组内创建新项目。拖到 **新建未分组项目** 区域会创建不属于任何侧栏组的新项目。右键菜单的 **移动标签页到项目** 也支持其他窗口。移动会保留运行中的终端和窗格布局。目标项目必须具有相同的本地或 SSH 位置;从标签创建新项目时会继承来源位置。包含 Diff 的标签保留在原项目中。 ### 标签页标题 @@ -73,7 +89,9 @@ Directory** 则交还自动判断。 Zshell 会随着你的操作持续快照布局,所以退出再打开会拿回: - 每个项目,顺序不变 -- 每个标签页,包括自定义名称和固定的目录 +- 每个标签页,包括自定义名称、固定状态和颜色标记 +- 侧边栏分组与标签页分组的名称、顺序、成员和折叠状态 +- 固定的项目目录和空项目 - 每个标签页里的窗格布局 - 哪些侧边栏是打开的、右侧选中的是哪个面板 diff --git a/web/content/docs/quick-launch.mdx b/web/content/docs/quick-launch.mdx index c10c5d8..8eb8d08 100644 --- a/web/content/docs/quick-launch.mdx +++ b/web/content/docs/quick-launch.mdx @@ -10,8 +10,9 @@ entry is either a **command** — anything you type often, like `npm run dev` or ## Launching -Type to fuzzy-filter the list, pick a row, and press Return (or -double-click it). The entry runs in a fresh session of the selected project — +Type a name, command, connection target, or group to fuzzy-filter the list. +Click a row, or select it with the arrow keys and press Return. +The entry runs in a fresh session of the selected project — the same flow as any other new terminal, with its own tab and directory. When the command finishes or the connection closes, the pane drops back to a @@ -24,6 +25,9 @@ close itself. - Cmd+E — edit the selected entry - Cmd+Delete — delete the selected entry +Use the optional **Group** field to organize entries into named sections. +The edit and delete buttons act on their entry without launching it. + A command entry can pin a **working directory** it always starts in; without one it follows the project like a normal session. An SSH entry takes the host, an optional user and port, and free-form extra `ssh` options such as `-L diff --git a/web/content/docs/quick-launch.zh.mdx b/web/content/docs/quick-launch.zh.mdx index a1c912c..89c1d06 100644 --- a/web/content/docs/quick-launch.zh.mdx +++ b/web/content/docs/quick-launch.zh.mdx @@ -7,7 +7,7 @@ description: 把反复使用的命令和 SSH 连接存成条目,一键启动 ## 启动 -输入即模糊过滤,选中一行按 Return(或直接双击)。条目会在所选项目的一个全新会话里运行——和普通新终端走同一条路,有自己的标签页和工作目录。 +输入名称、命令、连接目标或分组名即可模糊过滤。单击条目,或用方向键选中后按 Return,条目会在所选项目的一个全新会话里运行,有自己的标签页和工作目录。 命令结束或连接断开后,面板会落回一个正常的 shell 提示符,而不是整个消失。你启动的东西不会悄悄把自己关掉。 @@ -17,6 +17,8 @@ description: 把反复使用的命令和 SSH 连接存成条目,一键启动 - Cmd+E——编辑选中的条目 - Cmd+Delete——删除选中的条目 +填写可选的**分组**字段,可以把条目整理到命名分区。行内的编辑和删除按钮只操作对应条目,不会启动它。 + 命令条目可以固定一个**工作目录**,每次都从这里启动;不填就跟随项目,和普通会话一样。SSH 条目填主机、可选的用户和端口,外加自由格式的 `ssh` 参数,比如 `-L 8080:localhost:80`。 ## 条目存在哪