From 6d80388155fe7cfd31005c20fc1c362bf6eaeeb9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 13 Apr 2025 12:45:37 -0700 Subject: [PATCH 01/10] macOS: only emit a mouse exited position if we're not dragging Fixes #7071 When the mouse is being actively dragged, AppKit continues to emit mouseDragged events which will update our position appropriately. The mouseExit event we were sending sends a synthetic (-1, -1) position which was causing a scroll up. --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index c6a3d7629..230d3a9e2 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -743,6 +743,13 @@ extension Ghostty { override func mouseExited(with event: NSEvent) { guard let surface = self.surface else { return } + // If the mouse is being dragged then we don't have to emit + // this because we get mouse drag events even if we've already + // exited the viewport (i.e. mouseDragged) + if NSEvent.pressedMouseButtons != 0 { + return + } + // Negative values indicate cursor has left the viewport let mods = Ghostty.ghosttyMods(event.modifierFlags) ghostty_surface_mouse_pos(surface, -1, -1, mods) From 6d3f97ec1ec3a56d6dd83d25790e3fa711e40815 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 13 Apr 2025 14:37:38 -0700 Subject: [PATCH 02/10] Mouse drag while clicked should cancel any mouse link actions Fixes #7077 This follows pretty standard behavior across native or popular applications on both platforms macOS and Linux. The basic behavior is that if you do a mouse down event and then drag the mouse beyond the current character, then any mouse up actions are canceled (beyond emiting the event itself). This fixes a specific scenario where you could do the following: 1. Click anywhere (mouse down) 2. Drag over a valid link 3. Press command/control (to activate the link) 4. Release the mouse button (mouse up) 5. The link is triggered Now, step 3 and step 5 do not happen. Links are not even highlighted in this scenario. This matches iTerm2 on macOS which has a similar command-to-activate-links behavior. --- src/Surface.zig | 100 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 68 insertions(+), 32 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 89031a1b5..da8662040 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1031,9 +1031,64 @@ fn mouseRefreshLinks( // If the position is outside our viewport, do nothing if (pos.x < 0 or pos.y < 0) return; + // Update the last point that we checked for links so we don't + // recheck if the mouse moves some pixels to the same point. self.mouse.link_point = pos_vp; - if (try self.linkAtPos(pos)) |link| { + // We use an arena for everything below to make things easy to clean up. + // In the case we don't do any allocs this is very cheap to setup + // (effectively just struct init). + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Get our link at the current position. This returns null if there + // isn't a link OR if we shouldn't be showing links for some reason + // (see further comments for cases). + const link_: ?apprt.action.MouseOverLink = link: { + // If we clicked and our mouse moved cells then we never + // highlight links until the mouse is unclicked. This follows + // standard macOS and Linux behavior where a click and drag cancels + // mouse actions. + const left_idx = @intFromEnum(input.MouseButton.left); + if (self.mouse.click_state[left_idx] == .press) click: { + const pin = self.mouse.left_click_pin orelse break :click; + const click_pt = self.io.terminal.screen.pages.pointFromPin( + .viewport, + pin.*, + ) orelse break :click; + + if (!click_pt.coord().eql(pos_vp)) { + log.debug("mouse moved while left click held, ignoring link hover", .{}); + break :link null; + } + } + + const link = (try self.linkAtPos(pos)) orelse break :link null; + switch (link[0]) { + .open => { + const str = try self.io.terminal.screen.selectionString(alloc, .{ + .sel = link[1], + .trim = false, + }); + break :link .{ .url = str }; + }, + + ._open_osc8 => { + // Show the URL in the status bar + const pin = link[1].start(); + const uri = self.osc8URI(pin) orelse { + log.warn("failed to get URI for OSC8 hyperlink", .{}); + break :link null; + }; + break :link .{ .url = uri }; + }, + } + }; + + // If we found a link, setup our internal state and notify the + // apprt so it can highlight it. + if (link_) |link| { self.renderer_state.mouse.point = pos_vp; self.mouse.over_link = true; self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; @@ -1042,38 +1097,18 @@ fn mouseRefreshLinks( .mouse_shape, .pointer, ); - - switch (link[0]) { - .open => { - const str = try self.io.terminal.screen.selectionString(self.alloc, .{ - .sel = link[1], - .trim = false, - }); - defer self.alloc.free(str); - _ = try self.rt_app.performAction( - .{ .surface = self }, - .mouse_over_link, - .{ .url = str }, - ); - }, - - ._open_osc8 => link: { - // Show the URL in the status bar - const pin = link[1].start(); - const uri = self.osc8URI(pin) orelse { - log.warn("failed to get URI for OSC8 hyperlink", .{}); - break :link; - }; - _ = try self.rt_app.performAction( - .{ .surface = self }, - .mouse_over_link, - .{ .url = uri }, - ); - }, - } - + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + link, + ); try self.queueRender(); - } else if (over_link) { + return; + } + + // No link, if we're previously over a link then we need to clear + // the over-link apprt state. + if (over_link) { _ = try self.rt_app.performAction( .{ .surface = self }, .mouse_shape, @@ -1085,6 +1120,7 @@ fn mouseRefreshLinks( .{ .url = "" }, ); try self.queueRender(); + return; } } From b932d3552670f9dc91f35e3f6cd08b879a7cf6d2 Mon Sep 17 00:00:00 2001 From: cryptocode Date: Mon, 14 Apr 2025 16:27:07 +0200 Subject: [PATCH 03/10] i18: fix minor Norwegian grammar issues Changes the translation of 'clipboard' to definite singular form, and removes a misplaced verb. --- po/nb_NO.UTF-8.po | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/po/nb_NO.UTF-8.po b/po/nb_NO.UTF-8.po index ab6252f85..bd7c8876a 100644 --- a/po/nb_NO.UTF-8.po +++ b/po/nb_NO.UTF-8.po @@ -4,13 +4,14 @@ # Hanna Rose , 2025. # Uzair Aftab , 2025. # Christoffer Tønnessen , 2025. +# cryptocode , 2025. # msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"PO-Revision-Date: 2025-03-19 09:52+0100\n" -"Last-Translator: Christoffer Tønnessen \n" +"PO-Revision-Date: 2025-04-14 16:25+0200\n" +"Last-Translator: cryptocode \n" "Language-Team: Norwegian Bokmal \n" "Language: nb\n" "MIME-Version: 1.0\n" @@ -162,7 +163,7 @@ msgstr "Avslutt" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 msgid "Authorize Clipboard Access" -msgstr "Gi tilgang til utklippstavle" +msgstr "Gi tilgang til utklippstavlen" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 msgid "" @@ -187,7 +188,7 @@ msgid "" "An application is attempting to write to the clipboard. The current " "clipboard contents are shown below." msgstr "" -"En applikasjon er forsøker å skrive til utklippstavlen. Gjeldende " +"En applikasjon forsøker å skrive til utklippstavlen. Gjeldende " "utklippstavleinnhold er vist nedenfor." #: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 @@ -208,7 +209,7 @@ msgstr "Ghostty: Terminalinspektør" #: src/apprt/gtk/Surface.zig:1243 msgid "Copied to clipboard" -msgstr "Kopiert til utklippstavle" +msgstr "Kopiert til utklippstavlen" #: src/apprt/gtk/CloseDialog.zig:47 msgid "Close" From a0760cabd65fd4da46c6832bd44318c9526842d4 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Mon, 14 Apr 2025 21:43:02 +0800 Subject: [PATCH 04/10] gtk: implement bell Co-authored-by: Jeffrey C. Ollie --- include/ghostty.h | 1 + src/Surface.zig | 10 ++++++++++ src/apprt/action.zig | 3 +++ src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 8 ++++++++ src/apprt/gtk/Surface.zig | 4 ++++ src/apprt/surface.zig | 3 +++ src/termio/stream_handler.zig | 5 ++--- 8 files changed, 32 insertions(+), 3 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 2dc1bffef..f30275b2c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -601,6 +601,7 @@ typedef enum { GHOSTTY_ACTION_RELOAD_CONFIG, GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, + GHOSTTY_ACTION_RING_BELL, } ghostty_action_tag_e; typedef union { diff --git a/src/Surface.zig b/src/Surface.zig index 46fa476f7..03329fcb5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -932,6 +932,16 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .present_surface => try self.presentSurface(), .password_input => |v| try self.passwordInput(v), + + .ring_bell => { + _ = self.rt_app.performAction( + .{ .surface = self }, + .ring_bell, + {}, + ) catch |err| { + log.warn("apprt failed to ring bell={}", .{err}); + }; + }, } } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 30cb2fa5e..30cbfb1e1 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -244,6 +244,8 @@ pub const Action = union(Key) { /// Closes the currently focused window. close_window, + ring_bell, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -287,6 +289,7 @@ pub const Action = union(Key) { reload_config, config_change, close_window, + ring_bell, }; /// Sync with: ghostty_action_u diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 998f88022..c5ee802c4 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -246,6 +246,7 @@ pub const App = struct { .toggle_maximize, .prompt_title, .reset_window_size, + .ring_bell, => { log.info("unimplemented action={}", .{action}); return false; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index b4bebe8ee..d2e39c4c2 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -484,6 +484,7 @@ pub fn performAction( .prompt_title => try self.promptTitle(target), .toggle_quick_terminal => return try self.toggleQuickTerminal(), .secure_input => self.setSecureInput(target, value), + .ring_bell => try self.ringBell(target), // Unimplemented .close_all_windows, @@ -775,6 +776,13 @@ fn toggleQuickTerminal(self: *App) !bool { return true; } +fn ringBell(_: *App, target: apprt.Target) !void { + switch (target) { + .app => {}, + .surface => |surface| try surface.rt_surface.ringBell(), + } +} + fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { switch (mode) { .start => self.startQuitTimer(), diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index fe05fa63b..2ba21c871 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2439,3 +2439,7 @@ pub fn setSecureInput(self: *Surface, value: apprt.action.SecureInput) void { .toggle => self.is_secure_input = !self.is_secure_input, } } + +pub fn ringBell(self: *Surface) !void { + } +} diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index f3fd71432..6de41c544 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -81,6 +81,9 @@ pub const Message = union(enum) { /// The terminal has reported a change in the working directory. pwd_change: WriteReq, + /// The terminal encountered a bell character. + ring_bell, + pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 43d2888d2..299c7cd45 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -325,9 +325,8 @@ pub const StreamHandler = struct { try self.terminal.printRepeat(count); } - pub fn bell(self: StreamHandler) !void { - _ = self; - log.info("BELL", .{}); + pub fn bell(self: *StreamHandler) !void { + self.surfaceMessageWriter(.ring_bell); } pub fn backspace(self: *StreamHandler) !void { From 10a591fba25ac3605d7e46994c21a34f2f8a2b6a Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Mon, 14 Apr 2025 21:43:02 +0800 Subject: [PATCH 05/10] gtk(bell): use `gdk.Surface.beep` for bell Co-authored-by: Jeffrey C. Ollie --- src/apprt/gtk/Surface.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 2ba21c871..230c9a3c3 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2441,5 +2441,13 @@ pub fn setSecureInput(self: *Surface, value: apprt.action.SecureInput) void { } pub fn ringBell(self: *Surface) !void { + const window = self.container.window() orelse { + log.warn("failed to ring bell: surface is not attached to any window", .{}); + return; + }; + + // System beep + if (window.window.as(gtk.Native).getSurface()) |surface| { + surface.beep(); } } From abd7d9202b977c62924769779a24c3bc6d8af964 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Mon, 14 Apr 2025 21:43:02 +0800 Subject: [PATCH 06/10] gtk(bell): mark tab as needing attention on bell --- src/apprt/gtk/Surface.zig | 8 ++++++++ src/apprt/gtk/TabView.zig | 22 ++++++++++++++-------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 230c9a3c3..76de2a312 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2450,4 +2450,12 @@ pub fn ringBell(self: *Surface) !void { if (window.window.as(gtk.Native).getSurface()) |surface| { surface.beep(); } + + // Mark tab as needing attention + if (self.container.tab()) |tab| tab: { + const page = window.notebook.getTabPage(tab) orelse break :tab; + + // Need attention if we're not the currently selected tab + if (page.getSelected() == 0) page.setNeedsAttention(@intFromBool(true)); + } } diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig index 85a9bbcb2..ddd0951d2 100644 --- a/src/apprt/gtk/TabView.zig +++ b/src/apprt/gtk/TabView.zig @@ -114,9 +114,12 @@ pub fn gotoNthTab(self: *TabView, position: c_int) bool { return true; } +pub fn getTabPage(self: *TabView, tab: *Tab) ?*adw.TabPage { + return self.tab_view.getPage(tab.box.as(gtk.Widget)); +} + pub fn getTabPosition(self: *TabView, tab: *Tab) ?c_int { - const page = self.tab_view.getPage(tab.box.as(gtk.Widget)); - return self.tab_view.getPagePosition(page); + return self.tab_view.getPagePosition(self.getTabPage(tab) orelse return null); } pub fn gotoPreviousTab(self: *TabView, tab: *Tab) bool { @@ -161,17 +164,16 @@ pub fn moveTab(self: *TabView, tab: *Tab, position: c_int) void { } pub fn reorderPage(self: *TabView, tab: *Tab, position: c_int) void { - const page = self.tab_view.getPage(tab.box.as(gtk.Widget)); - _ = self.tab_view.reorderPage(page, position); + _ = self.tab_view.reorderPage(self.getTabPage(tab) orelse return, position); } pub fn setTabTitle(self: *TabView, tab: *Tab, title: [:0]const u8) void { - const page = self.tab_view.getPage(tab.box.as(gtk.Widget)); + const page = self.getTabPage(tab) orelse return; page.setTitle(title.ptr); } pub fn setTabTooltip(self: *TabView, tab: *Tab, tooltip: [:0]const u8) void { - const page = self.tab_view.getPage(tab.box.as(gtk.Widget)); + const page = self.getTabPage(tab) orelse return; page.setTooltip(tooltip.ptr); } @@ -203,8 +205,7 @@ pub fn closeTab(self: *TabView, tab: *Tab) void { if (n > 1) self.forcing_close = false; } - const page = self.tab_view.getPage(tab.box.as(gtk.Widget)); - self.tab_view.closePage(page); + if (self.getTabPage(tab)) |page| self.tab_view.closePage(page); // If we have no more tabs we close the window if (self.nPages() == 0) { @@ -260,6 +261,11 @@ fn adwTabViewCreateWindow( fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callconv(.C) void { const page = self.tab_view.getSelectedPage() orelse return; + + // If the tab was previously marked as needing attention + // (e.g. due to a bell character), we now unmark that + page.setNeedsAttention(@intFromBool(false)); + const title = page.getTitle(); self.window.setTitle(std.mem.span(title)); } From 3a973c692a2bebc879ab285af3cc21be4d4f3cfe Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Mon, 14 Apr 2025 23:27:05 +0800 Subject: [PATCH 07/10] gtk(bell): add `bell-features` config option Co-authored-by: Jeffrey C. Ollie --- src/apprt/gtk/Surface.zig | 4 +++- src/config/Config.zig | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 76de2a312..e99fe29ce 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2441,13 +2441,15 @@ pub fn setSecureInput(self: *Surface, value: apprt.action.SecureInput) void { } pub fn ringBell(self: *Surface) !void { + const features = self.app.config.@"bell-features"; const window = self.container.window() orelse { log.warn("failed to ring bell: surface is not attached to any window", .{}); return; }; // System beep - if (window.window.as(gtk.Native).getSurface()) |surface| { + if (features.system) system: { + const surface = window.window.as(gtk.Native).getSurface() orelse break :system; surface.beep(); } diff --git a/src/config/Config.zig b/src/config/Config.zig index 9cd285d3b..7997b9200 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1848,6 +1848,22 @@ keybind: Keybinds = .{}, /// open terminals. @"custom-shader-animation": CustomShaderAnimation = .true, +/// The list of enabled features that are activated after encountering +/// a bell character. +/// +/// Valid values are: +/// +/// * `system` (default) +/// +/// Instructs the system to notify the user using built-in system functions. +/// This could result in an audiovisual effect, a notification, or something +/// else entirely. Changing these effects require altering system settings: +/// for instance under the "Sound > Alert Sound" setting in GNOME, +/// or the "Accessibility > System Bell" settings in KDE Plasma. +/// +/// Currently only implemented on Linux. +@"bell-features": BellFeatures = .{}, + /// Control the in-app notifications that Ghostty shows. /// /// On Linux (GTK), in-app notifications show up as toasts. Toasts appear @@ -5669,6 +5685,11 @@ pub const AppNotifications = packed struct { @"clipboard-copy": bool = true, }; +/// See bell-features +pub const BellFeatures = packed struct { + system: bool = false, +}; + /// See mouse-shift-capture pub const MouseShiftCapture = enum { false, From 453e6590e8a648b7bf6617afb71e7fc07ad782a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 14 Apr 2025 10:37:54 -0700 Subject: [PATCH 08/10] macOS: non-native fullscreen should not hide menu on fullscreen space Fixes #7075 We have to use private APIs for this, I couldn't find a reliable way otherwise. --- macos/Ghostty.xcodeproj/project.pbxproj | 18 ++++- .../QuickTerminalController.swift | 12 +-- macos/Sources/Helpers/Fullscreen.swift | 13 ++- .../Sources/Helpers/NSWindow+Extension.swift | 8 ++ macos/Sources/Helpers/Private/CGS.swift | 81 +++++++++++++++++++ .../Sources/Helpers/{ => Private}/Dock.swift | 0 6 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 macos/Sources/Helpers/NSWindow+Extension.swift create mode 100644 macos/Sources/Helpers/Private/CGS.swift rename macos/Sources/Helpers/{ => Private}/Dock.swift (100%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b4c00946c..b69541504 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -55,6 +55,8 @@ A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; }; A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; }; + A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; + A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; A59630972AEE163600D64628 /* HostingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630962AEE163600D64628 /* HostingWindow.swift */; }; A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; }; @@ -154,6 +156,8 @@ A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; }; A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = ""; }; A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; }; + A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; + A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A59630962AEE163600D64628 /* HostingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingWindow.swift; sourceTree = ""; }; A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = ""; }; @@ -274,13 +278,13 @@ A534263D2A7DCBB000EBB7A2 /* Helpers */ = { isa = PBXGroup; children = ( + A5874D9B2DAD781100E83852 /* Private */, A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */, A5A6F7292CC41B8700B232A5 /* Xcode.swift */, A5CEAFFE29C2410700646FDA /* Backport.swift */, A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, - A5A2A3C92D4445E20033CF96 /* Dock.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, @@ -293,6 +297,7 @@ A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, + A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */, A5CA378D2D31D6C100931030 /* Weak.swift */, @@ -403,6 +408,15 @@ path = "Secure Input"; sourceTree = ""; }; + A5874D9B2DAD781100E83852 /* Private */ = { + isa = PBXGroup; + children = ( + A5874D982DAD751A00E83852 /* CGS.swift */, + A5A2A3C92D4445E20033CF96 /* Dock.swift */, + ); + path = Private; + sourceTree = ""; + }; A59630982AEE1C4400D64628 /* Terminal */ = { isa = PBXGroup; children = ( @@ -634,6 +648,7 @@ A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, + A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, @@ -669,6 +684,7 @@ A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, + A5874D992DAD751B00E83852 /* CGS.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index fac3a2fbb..896b25326 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -3,12 +3,6 @@ import Cocoa import SwiftUI import GhosttyKit -// This is a Apple's private function that we need to call to get the active space. -@_silgen_name("CGSGetActiveSpace") -func CGSGetActiveSpace(_ cid: Int) -> size_t -@_silgen_name("CGSMainConnectionID") -func CGSMainConnectionID() -> Int - /// Controller for the "quick" terminal. class QuickTerminalController: BaseTerminalController { override var windowNibName: NSNib.Name? { "QuickTerminal" } @@ -25,7 +19,7 @@ class QuickTerminalController: BaseTerminalController { private var previousApp: NSRunningApplication? = nil // The active space when the quick terminal was last shown. - private var previousActiveSpace: size_t = 0 + private var previousActiveSpace: CGSSpace? = nil /// Non-nil if we have hidden dock state. private var hiddenDock: HiddenDock? = nil @@ -154,7 +148,7 @@ class QuickTerminalController: BaseTerminalController { animateOut() case .move: - let currentActiveSpace = CGSGetActiveSpace(CGSMainConnectionID()) + let currentActiveSpace = CGSSpace.active() if previousActiveSpace == currentActiveSpace { // We haven't moved spaces. We lost focus to another app on the // current space. Animate out. @@ -224,7 +218,7 @@ class QuickTerminalController: BaseTerminalController { } // Set previous active space - self.previousActiveSpace = CGSGetActiveSpace(CGSMainConnectionID()) + self.previousActiveSpace = CGSSpace.active() // Animate the window in animateWindowIn(window: window, from: position) diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 59865fc9e..1daf9f142 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -180,7 +180,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { } // Hide the menu if requested - if (properties.hideMenu) { + if (properties.hideMenu && savedState.menu) { hideMenu() } @@ -224,7 +224,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { if savedState.dock { unhideDock() } - unhideMenu() + if (properties.hideMenu && savedState.menu) { + unhideMenu() + } // Restore our saved state window.styleMask = savedState.styleMask @@ -340,6 +342,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { let contentFrame: NSRect let styleMask: NSWindow.StyleMask let dock: Bool + let menu: Bool init?(_ window: NSWindow) { guard let contentView = window.contentView else { return nil } @@ -350,6 +353,12 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.contentFrame = window.convertToScreen(contentView.frame) self.styleMask = window.styleMask self.dock = window.screen?.hasDock ?? false + + // We hide the menu only if this window is not on any fullscreen + // spaces. We do this because fullscreen spaces already hide the + // menu and if we insert/remove this presentation option we get + // issues (see #7075) + self.menu = CGSSpace.list(for: window.cgWindowId).allSatisfy { $0.type != .fullscreen } } } } diff --git a/macos/Sources/Helpers/NSWindow+Extension.swift b/macos/Sources/Helpers/NSWindow+Extension.swift new file mode 100644 index 000000000..c7523bdb7 --- /dev/null +++ b/macos/Sources/Helpers/NSWindow+Extension.swift @@ -0,0 +1,8 @@ +import AppKit + +extension NSWindow { + /// Get the CGWindowID type for the window (used for low level CoreGraphics APIs). + var cgWindowId: CGWindowID { + CGWindowID(windowNumber) + } +} diff --git a/macos/Sources/Helpers/Private/CGS.swift b/macos/Sources/Helpers/Private/CGS.swift new file mode 100644 index 000000000..f9c20afe9 --- /dev/null +++ b/macos/Sources/Helpers/Private/CGS.swift @@ -0,0 +1,81 @@ +import AppKit + +// MARK: - CGS Private API Declarations + +typealias CGSConnectionID = Int32 +typealias CGSSpaceID = size_t + +@_silgen_name("CGSMainConnectionID") +private func CGSMainConnectionID() -> CGSConnectionID + +@_silgen_name("CGSGetActiveSpace") +private func CGSGetActiveSpace(_ cid: CGSConnectionID) -> CGSSpaceID + +@_silgen_name("CGSSpaceGetType") +private func CGSSpaceGetType(_ cid: CGSConnectionID, _ spaceID: CGSSpaceID) -> CGSSpaceType + +@_silgen_name("CGSCopySpacesForWindows") +func CGSCopySpacesForWindows( + _ cid: CGSConnectionID, + _ mask: CGSSpaceMask, + _ windowIDs: CFArray +) -> Unmanaged? + +// MARK: - CGS Space + +/// https://github.com/NUIKit/CGSInternal/blob/c4f6f559d624dc1cfc2bf24c8c19dbf653317fcf/CGSSpace.h#L40 +/// converted to Swift +struct CGSSpaceMask: OptionSet { + let rawValue: UInt32 + + static let includesCurrent = CGSSpaceMask(rawValue: 1 << 0) + static let includesOthers = CGSSpaceMask(rawValue: 1 << 1) + static let includesUser = CGSSpaceMask(rawValue: 1 << 2) + + static let includesVisible = CGSSpaceMask(rawValue: 1 << 16) + + static let currentSpace: CGSSpaceMask = [.includesUser, .includesCurrent] + static let otherSpaces: CGSSpaceMask = [.includesOthers, .includesCurrent] + static let allSpaces: CGSSpaceMask = [.includesUser, .includesOthers, .includesCurrent] + static let allVisibleSpaces: CGSSpaceMask = [.includesVisible, .allSpaces] +} + +/// Represents a unique identifier for a macOS Space (Desktop, Fullscreen, etc). +struct CGSSpace: Hashable, CustomStringConvertible { + let rawValue: CGSSpaceID + + var description: String { + "SpaceID(\(rawValue))" + } + + /// Returns the currently active space. + static func active() -> CGSSpace { + let space = CGSGetActiveSpace(CGSMainConnectionID()) + return .init(rawValue: space) + } + + /// List the sapces for the given window. + static func list(for windowID: CGWindowID, mask: CGSSpaceMask = .allSpaces) -> [CGSSpace] { + guard let spaces = CGSCopySpacesForWindows( + CGSMainConnectionID(), + mask, + [windowID] as CFArray + ) else { return [] } + guard let spaceIDs = spaces.takeRetainedValue() as? [CGSSpaceID] else { return [] } + return spaceIDs.map(CGSSpace.init) + } +} + +// MARK: - CGS Space Types + +enum CGSSpaceType: UInt32 { + case user = 0 + case system = 2 + case fullscreen = 4 +} + +extension CGSSpace { + var type: CGSSpaceType { + CGSSpaceGetType(CGSMainConnectionID(), rawValue) + } +} diff --git a/macos/Sources/Helpers/Dock.swift b/macos/Sources/Helpers/Private/Dock.swift similarity index 100% rename from macos/Sources/Helpers/Dock.swift rename to macos/Sources/Helpers/Private/Dock.swift From d1c15dbf0768ecdb7fdde6a590c1d4b252b9532e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 14 Apr 2025 10:50:30 -0700 Subject: [PATCH 09/10] macOS: quick terminal should retain menu if not frontmost This is a bug I noticed in the following scenario: 1. Open Ghostty 2. Fullscreen normal terminal window (native fullscreen) 3. Open quick terminal 4. Move spaces, QT follows 5. Fullscreen the quick terminal The result was that the menu bar would not disappear since our app is not frontmost but we set the fullscreen frame such that we expected it. --- .../QuickTerminalController.swift | 34 ++++++++++++++++--- macos/Sources/Helpers/Fullscreen.swift | 11 ++++-- .../Helpers/NSApplication+Extension.swift | 12 +++++++ macos/Sources/Helpers/Private/CGS.swift | 2 +- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 896b25326..6e5607c6f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -45,7 +45,7 @@ class QuickTerminalController: BaseTerminalController { object: nil) center.addObserver( self, - selector: #selector(onToggleFullscreen), + selector: #selector(onToggleFullscreen(notification:)), name: Ghostty.Notification.ghosttyToggleFullscreen, object: nil) center.addObserver( @@ -154,8 +154,18 @@ class QuickTerminalController: BaseTerminalController { // current space. Animate out. animateOut() } else { - // We've moved to a different space. Bring the quick terminal back - // into view. + // We've moved to a different space. + + // If we're fullscreen, we need to exit fullscreen because the visible + // bounds may have changed causing a new behavior. + if let fullscreenStyle, fullscreenStyle.isFullscreen { + fullscreenStyle.exit() + DispatchQueue.main.async { + self.onToggleFullscreen() + } + } + + // Make the window visible again on this space DispatchQueue.main.async { self.window?.makeKeyAndOrderFront(nil) } @@ -479,9 +489,23 @@ class QuickTerminalController: BaseTerminalController { @objc private func onToggleFullscreen(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard target == self.focusedSurface else { return } + onToggleFullscreen() + } - // We ignore the requested mode and always use non-native for the quick terminal - toggleFullscreen(mode: .nonNative) + private func onToggleFullscreen() { + // We ignore the configured fullscreen style and always use non-native + // because the way the quick terminal works doesn't support native. + // + // An additional detail is that if the is NOT frontmost, then our + // NSApp.presentationOptions will not take effect so we must always + // do the visible menu mode since we can't get rid of the menu. + let mode: FullscreenMode = if (NSApp.isFrontmost) { + .nonNative + } else { + .nonNativeVisibleMenu + } + + toggleFullscreen(mode: mode) } @objc private func ghosttyConfigDidChange(_ notification: Notification) { diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 1daf9f142..b6fb08271 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -275,7 +275,8 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // calculate this ourselves. var frame = screen.frame - if (!properties.hideMenu) { + if (!NSApp.presentationOptions.contains(.autoHideMenuBar) && + !NSApp.presentationOptions.contains(.hideMenuBar)) { // We need to subtract the menu height since we're still showing it. frame.size.height -= NSApp.mainMenu?.menuBarHeight ?? 0 @@ -358,7 +359,13 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // spaces. We do this because fullscreen spaces already hide the // menu and if we insert/remove this presentation option we get // issues (see #7075) - self.menu = CGSSpace.list(for: window.cgWindowId).allSatisfy { $0.type != .fullscreen } + let activeSpace = CGSSpace.active() + let spaces = CGSSpace.list(for: window.cgWindowId) + if spaces.contains(activeSpace) { + self.menu = activeSpace.type != .fullscreen + } else { + self.menu = spaces.allSatisfy { $0.type != .fullscreen } + } } } } diff --git a/macos/Sources/Helpers/NSApplication+Extension.swift b/macos/Sources/Helpers/NSApplication+Extension.swift index 0580cd5fc..d8e41523a 100644 --- a/macos/Sources/Helpers/NSApplication+Extension.swift +++ b/macos/Sources/Helpers/NSApplication+Extension.swift @@ -1,5 +1,7 @@ import Cocoa +// MARK: Presentation Options + extension NSApplication { private static var presentationOptionCounts: [NSApplication.PresentationOptions.Element: UInt] = [:] @@ -29,3 +31,13 @@ extension NSApplication.PresentationOptions.Element: @retroactive Hashable { hasher.combine(rawValue) } } + +// MARK: Frontmost + +extension NSApplication { + /// True if the application is frontmost. This isn't exactly the same as isActive because + /// an app can be active but not be frontmost if the window with activity is an NSPanel. + var isFrontmost: Bool { + NSWorkspace.shared.frontmostApplication?.bundleIdentifier == Bundle.main.bundleIdentifier + } +} diff --git a/macos/Sources/Helpers/Private/CGS.swift b/macos/Sources/Helpers/Private/CGS.swift index f9c20afe9..0d3b9aa4c 100644 --- a/macos/Sources/Helpers/Private/CGS.swift +++ b/macos/Sources/Helpers/Private/CGS.swift @@ -54,7 +54,7 @@ struct CGSSpace: Hashable, CustomStringConvertible { return .init(rawValue: space) } - /// List the sapces for the given window. + /// List the spaces for the given window. static func list(for windowID: CGWindowID, mask: CGSSpaceMask = .allSpaces) -> [CGSSpace] { guard let spaces = CGSCopySpacesForWindows( CGSMainConnectionID(), From 8bc91933cda382f7eb2b913e14e59d4385107fe2 Mon Sep 17 00:00:00 2001 From: trag1c Date: Mon, 14 Apr 2025 23:40:41 +0200 Subject: [PATCH 10/10] ci: add logging to localization-review script --- .github/scripts/request_review.py | 162 ++++++++++++++++++++++-------- 1 file changed, 118 insertions(+), 44 deletions(-) diff --git a/.github/scripts/request_review.py b/.github/scripts/request_review.py index 1a53e82e4..d799e7c58 100644 --- a/.github/scripts/request_review.py +++ b/.github/scripts/request_review.py @@ -2,113 +2,187 @@ # requires-python = ">=3.9" # dependencies = [ # "githubkit", +# "loguru", # ] # /// +from __future__ import annotations + import asyncio import os import re +import sys +from collections.abc import Iterator +from contextlib import contextmanager from itertools import chain from githubkit import GitHub +from githubkit.exception import RequestFailed +from loguru import logger ORG_NAME = "ghostty-org" REPO_NAME = "ghostty" ALLOWED_PARENT_TEAM = "localization" LOCALIZATION_TEAM_NAME_PATTERN = re.compile(r"[a-z]{2}_[A-Z]{2}") +LEVEL_MAP = {"DEBUG": "DBG", "WARNING": "WRN", "ERROR": "ERR"} + +logger.remove() +logger.add( + sys.stderr, + format=lambda record: ( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + f"{LEVEL_MAP[record['level'].name]} | " + "{function}:{line} - " + "{message}\n" + ), + backtrace=True, + diagnose=True, +) + + +@contextmanager +def log_fail(message: str, *, die: bool = True) -> Iterator[None]: + try: + yield + except RequestFailed as exc: + logger.error(message) + logger.error(exc) + logger.error(exc.response.raw_response.json()) + if die: + sys.exit(1) + gh = GitHub(os.environ["GITHUB_TOKEN"]) +with log_fail("Invalid token"): + # Do the simplest request as a test + gh.rest.rate_limit.get() + async def fetch_and_parse_codeowners() -> dict[str, str]: - content = ( - await gh.rest.repos.async_get_content( - ORG_NAME, - REPO_NAME, - "CODEOWNERS", - headers={"Accept": "application/vnd.github.raw+json"}, - ) - ).text + logger.debug("Fetching CODEOWNERS file...") + with log_fail("Failed to fetch CODEOWNERS file"): + content = ( + await gh.rest.repos.async_get_content( + ORG_NAME, + REPO_NAME, + "CODEOWNERS", + headers={"Accept": "application/vnd.github.raw+json"}, + ) + ).text + logger.debug("Parsing CODEOWNERS file...") codeowners: dict[str, str] = {} for line in content.splitlines(): if not line or line.lstrip().startswith("#"): continue + # This assumes that all entries only list one owner # and that this owner is a team (ghostty-org/foobar) path, owner = line.split() - codeowners[path.lstrip("/")] = owner.removeprefix(f"@{ORG_NAME}/") + path = path.lstrip("/") + owner = owner.removeprefix(f"@{ORG_NAME}/") + + if not is_localization_team(owner): + logger.debug(f"Skipping non-l11n codeowner {owner!r} for {path}") + continue + + codeowners[path] = owner + logger.debug(f"Found codeowner {owner!r} for {path}") return codeowners async def get_team_members(team_name: str) -> list[str]: - team = (await gh.rest.teams.async_get_by_name(ORG_NAME, team_name)).parsed_data + logger.debug(f"Fetching team {team_name!r}...") + with log_fail(f"Failed to fetch team {team_name!r}"): + team = (await gh.rest.teams.async_get_by_name(ORG_NAME, team_name)).parsed_data + if team.parent and team.parent.slug == ALLOWED_PARENT_TEAM: - members = ( - await gh.rest.teams.async_list_members_in_org(ORG_NAME, team_name) - ).parsed_data - return [m.login for m in members] + logger.debug(f"Fetching team {team_name!r} members...") + with log_fail(f"Failed to fetch team {team_name!r} members"): + resp = await gh.rest.teams.async_list_members_in_org(ORG_NAME, team_name) + members = [m.login for m in resp.parsed_data] + logger.debug(f"Team {team_name!r} members: {', '.join(members)}") + return members + + logger.warning(f"Team {team_name} does not have a {ALLOWED_PARENT_TEAM!r} parent") return [] async def get_changed_files(pr_number: int) -> list[str]: - diff_entries = ( - await gh.rest.pulls.async_list_files( - ORG_NAME, - REPO_NAME, - pr_number, - per_page=3000, - headers={"Accept": "application/vnd.github+json"}, - ) - ).parsed_data - return [d.filename for d in diff_entries] - - -async def request_review(pr_number: int, pr_author: str, *users: str) -> None: - await asyncio.gather( - *( - gh.rest.pulls.async_request_reviewers( + logger.debug("Gathering changed files...") + with log_fail("Failed to gather changed files"): + diff_entries = ( + await gh.rest.pulls.async_list_files( ORG_NAME, REPO_NAME, pr_number, + per_page=3000, headers={"Accept": "application/vnd.github+json"}, - data={"reviewers": [user]}, ) - for user in users - if user != pr_author + ).parsed_data + return [d.filename for d in diff_entries] + + +async def request_review(pr_number: int, user: str, pr_author: str) -> None: + if user == pr_author: + logger.debug(f"Skipping review request for {user!r} (is PR author)") + logger.debug(f"Requesting review from {user!r}...") + with log_fail(f"Failed to request review from {user}", die=False): + await gh.rest.pulls.async_request_reviewers( + ORG_NAME, + REPO_NAME, + pr_number, + headers={"Accept": "application/vnd.github+json"}, + data={"reviewers": [user]}, ) - ) def is_localization_team(team_name: str) -> bool: return LOCALIZATION_TEAM_NAME_PATTERN.fullmatch(team_name) is not None +async def get_pr_author(pr_number: int) -> str: + logger.debug("Fetching PR author...") + with log_fail("Failed to fetch PR author"): + resp = await gh.rest.pulls.async_get(ORG_NAME, REPO_NAME, pr_number) + pr_author = resp.parsed_data.user.login + logger.debug(f"Found author: {pr_author!r}") + return pr_author + + async def main() -> None: + logger.debug("Reading PR number...") pr_number = int(os.environ["PR_NUMBER"]) + logger.debug(f"Starting review request process for PR #{pr_number}...") + changed_files = await get_changed_files(pr_number) - pr_author = ( - await gh.rest.pulls.async_get(ORG_NAME, REPO_NAME, pr_number) - ).parsed_data.user.login - localization_codewners = { - path: owner - for path, owner in (await fetch_and_parse_codeowners()).items() - if is_localization_team(owner) - } + logger.debug(f"Changed files: {', '.join(map(repr, changed_files))}") + + pr_author = await get_pr_author(pr_number) + codeowners = await fetch_and_parse_codeowners() found_owners = set[str]() for file in changed_files: - for path, owner in localization_codewners.items(): + logger.debug(f"Finding owner for {file!r}...") + for path, owner in codeowners.items(): if file.startswith(path): + logger.debug(f"Found owner: {owner!r}") break else: + logger.debug("No owner found") continue found_owners.add(owner) member_lists = await asyncio.gather( *(get_team_members(owner) for owner in found_owners) ) - await request_review(pr_number, pr_author, *chain.from_iterable(member_lists)) + await asyncio.gather( + *( + request_review(pr_number, user, pr_author) + for user in chain.from_iterable(member_lists) + ) + ) if __name__ == "__main__":