From 28cddeb17d0376f10af2ac353d59a55e4ec81b3f Mon Sep 17 00:00:00 2001 From: wzz6423 <2705704576@qq.com> Date: Mon, 14 Sep 2026 22:37:07 +0800 Subject: [PATCH] fix(workspace): complete session grouping and repair UI regressions Add native project and session-tab grouping with persistent membership, collapsed state, and transfers that retain terminal ownership. Repair dark Quick Launch rendering and the confirmed terminal, search, editor, SSH, and shortcut regressions from the recent PR review. Co-authored-by: Codex --- mac/Vendor/alacritty-bridge/src/lib.rs | 121 ++- .../AppKit/AppTerminalView+PublicInput.swift | 63 ++ mac/tests/test_file_content_search.py | 108 ++ mac/tests/test_ghostty_link_position.py | 240 +++++ mac/tests/test_project_file_search.py | 97 ++ mac/tests/test_prompt_selection.py | 23 + mac/tests/test_terminal_links.py | 86 ++ mac/zshell/AgentPalette.swift | 18 +- mac/zshell/AppKitProjectSidebarView.swift | 515 +++++++++ mac/zshell/AppKitSessionTabsView.swift | 516 +++++++++ mac/zshell/CommandPaletteView.swift | 29 +- mac/zshell/CommandShortcuts.swift | 14 +- mac/zshell/ContentView.swift | 976 ++---------------- mac/zshell/FileContentSearchModel.swift | 106 +- mac/zshell/FileViewerView.swift | 18 +- mac/zshell/GlobalTerminalOverlay.swift | 28 +- mac/zshell/Localizable.xcstrings | 128 +++ mac/zshell/Panes.swift | 1 + mac/zshell/PinContextMenuMonitor.swift | 20 +- mac/zshell/Project.swift | 146 ++- mac/zshell/ProjectFileSearch.swift | 8 +- mac/zshell/ProjectGroup.swift | 23 + mac/zshell/QuickLaunchEditorController.swift | 19 +- mac/zshell/QuickLaunchPanelController.swift | 91 +- mac/zshell/SSHProjectController.swift | 43 +- mac/zshell/SessionStore.swift | 16 +- .../Settings/CommandShortcutRecorder.swift | 9 +- .../Settings/FontThickenPreviewView.swift | 5 + .../Settings/SettingsAppearancePane.swift | 1 + mac/zshell/Settings/SettingsRows.swift | 6 +- .../Settings/SettingsShortcutsPane.swift | 6 +- mac/zshell/SidebarView.swift | 911 +--------------- mac/zshell/SourceTextEditor.swift | 37 +- mac/zshell/SyntaxHighlightPlugin.swift | 18 +- mac/zshell/TerminalManager.swift | 145 ++- mac/zshell/TerminalPromptQueue.swift | 8 +- mac/zshell/TerminalSession.swift | 41 +- mac/zshell/WorkspaceChromeViews.swift | 452 ++++++++ mac/zshell/ZshellAutomationProtocol.swift | 7 +- mac/zshell/ZshellTerminalView.swift | 8 +- mac/zshell/zshellApp.swift | 2 +- web/content/docs/projects.mdx | 50 +- web/content/docs/projects.zh.mdx | 22 +- web/content/docs/quick-launch.mdx | 8 +- web/content/docs/quick-launch.zh.mdx | 4 +- 45 files changed, 3187 insertions(+), 2006 deletions(-) create mode 100644 mac/tests/test_file_content_search.py create mode 100644 mac/tests/test_ghostty_link_position.py create mode 100644 mac/tests/test_project_file_search.py create mode 100644 mac/tests/test_terminal_links.py create mode 100644 mac/zshell/AppKitProjectSidebarView.swift create mode 100644 mac/zshell/AppKitSessionTabsView.swift create mode 100644 mac/zshell/WorkspaceChromeViews.swift 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`。 ## 条目存在哪