diff --git a/docs/macos.md b/docs/macos.md index 1d20d11..4c4e476 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -82,7 +82,7 @@ Focus lives in `Store`. In-app `#/` links go through `Store.route`: `#/task/ ## Window and layout -One window per saved server, sharing no UI: the windows are a `WindowGroup` (id `main`) whose value is the server's origin, so opening a server's window again brings forward the one already open. Each window has its own `Overlays`, so its switcher, dialogs, left-pane tab and key monitor act only on it, and its own pane-focus reporting. The window's title is the server's host, so the Window menu lists the servers, and its content sits inside a 3-point border in the server's colour (`Servers.color`, one of the task colours, picked from a row of swatches in Settings under Window colour and kept in the server's `ServerRecord`, so every Mac shows the same colour). A window with no server (New Server Window, or after Forget Server) shows the connection screen with no border. The panes follow the "UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. The title bar is the standard one; no byre controls go in it or in a toolbar. +One window per saved server, sharing no UI: the windows are a `WindowGroup` (id `main`) whose value is the server's origin, so opening a server's window again brings forward the one already open. The windows are tabs of one window: each sets `tabbingMode = .preferred` and, once shown, joins the tab group of another visible server window (`addTabbedWindow`), so the windows opened at launch end up tabbed together as New Server Window's do. Each window has its own `Overlays`, so its switcher, dialogs, left-pane tab and key monitor act only on it, and its own pane-focus reporting. The window's title is the server's host, so the Window menu lists the servers, and its content sits inside a 3-point border in the server's colour (`Servers.color`, one of the task colours, picked from a row of swatches in Settings under Window colour and kept in the server's `ServerRecord`, so every Mac shows the same colour). A window with no server (New Server Window, or after Forget Server) shows the connection screen with no border. The panes follow the "UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. The title bar is the standard one; no byre controls go in it or in a toolbar. At launch the first window takes the oldest saved server and opens a window for each of the others. A CloudKit import that brings a server saved on another Mac starts its connection, and a window showing an empty connection screen takes it. SwiftUI's own window restoration is off (`restorationBehavior(.disabled)`); the scene keeps the id `main` that the single-window builds used, because AppKit restores their saved window by that id and SwiftUI then opens no launch window at all when the id names no scene. @@ -149,7 +149,7 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima ## Keyboard and menus -The app menu has About, Check for Updates…, Forget Server (for the focused window's server), Hide, Hide Others and Quit; File has New Server Window ⌘N, which opens a window with the connection screen, and Open Server, listing every saved server by host; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items and one entry per open window. The Go commands act on the focused window (its `ServerFocus`, a focused scene value holding its store and `Overlays`), and are disabled while that window's alert or modal is open (`dialogOpen`); the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. The Ctrl variants (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay. +The app menu has About, Check for Updates…, Forget Server (for the focused window's server), Hide, Hide Others and Quit; File has New Server Window ⌘N, which opens a window with the connection screen, and Open Server, listing every saved server by host; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S, Previous Server ⌘[, Next Server ⌘] and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items and one entry per open window. The Go commands act on the focused window (its `ServerFocus`, a focused scene value holding its store and `Overlays`), and are disabled while that window's alert or modal is open (`dialogOpen`); the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. Previous and Next Server bring forward, or open, the window of the saved server before or after the focused window's, in saved order, wrapping round; from a window with no server it goes to the first. The Ctrl variants (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay. ## Connection diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift index d07e0b6..9ce8cc4 100644 --- a/mac/Sources/Byre/ByreApp.swift +++ b/mac/Sources/Byre/ByreApp.swift @@ -110,11 +110,23 @@ struct ByreCommands: Commands { goButton("Project", "p") goButton("Lead", "l") goButton("Merge", "s") + serverButton("Previous Server", "[", -1) + serverButton("Next Server", "]", 1) Divider() goButton("Keyboard Shortcuts", "/") } } + private func serverButton(_ label: String, _ key: Character, _ step: Int) -> some View { + Button(label) { + if let origin = Servers.shared.step(from: focus?.origin, by: step) { + openWindow(id: "main", value: origin) + } + } + .keyboardShortcut(KeyEquivalent(key)) + .disabled(Servers.shared.origins.isEmpty) + } + private func goButton(_ label: String, _ key: String) -> some View { Button(label) { if let store = focus?.store, let overlays = focus?.overlays { diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index 0b49c64..2f63229 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -88,6 +88,13 @@ final class Servers { saveRecords() } + func step(from origin: String?, by step: Int) -> String? { + guard let i = origin.flatMap({ origins.firstIndex(of: $0) }) else { + return origins.first + } + return origins[(i + step + origins.count) % origins.count] + } + func openAll() { if origins.isEmpty { openWindow?(nil) @@ -315,6 +322,7 @@ struct FatalView: View { } struct ServerFocus { + let origin: String? let store: Store? let overlays: Overlays let forget: (() -> Void)? @@ -340,7 +348,7 @@ struct ServerWindow: View { if connection != nil { forgetting = forget } - return ServerFocus(store: connection?.store, overlays: overlays, forget: forgetting) + return ServerFocus(origin: connection?.origin.absoluteString, store: connection?.store, overlays: overlays, forget: forgetting) } private var frameColor: Color { @@ -387,6 +395,9 @@ struct ServerWindow: View { var body: some View { framed .navigationTitle(connection?.origin.host() ?? "byre") + .background { + WindowReader(found: joinTabs) + } .focusedSceneValue(\.serverWindow, focus) .onChange(of: origin, initial: true) { old, new in moved(old, new) @@ -431,6 +442,16 @@ struct ServerWindow: View { Notifier.shared.deliver() } + private func joinTabs(_ window: NSWindow) { + window.tabbingMode = .preferred + DispatchQueue.main.async { + let host = NSApp.windows.first { $0 !== window && $0.isVisible && $0.tabbingIdentifier == window.tabbingIdentifier } + if let host, !(host.tabbedWindows ?? []).contains(window) { + host.addTabbedWindow(window, ordered: .above) + } + } + } + private func adopt(_ candidates: [String]) { guard origin == nil, form.serverText.isEmpty, !form.connecting, let next = candidates.first(where: { !Servers.shared.shown.contains($0) }) else { return diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index 5a972f7..63abce3 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -46,6 +46,7 @@ private let shortcuts: [(String, [(String, String)])] = [ ("⌘P", "Open the Project tab"), ("⌘L", "Open the Lead tab and its composer"), ("⌘S", "Merge the right pane's task"), + ("⌘[ ⌘]", "Bring the previous or next server's window forward"), ("⌘/", "Show these shortcuts"), ("Esc", "Close the switcher or a dialog"), ]),