Connect to several servers, one coloured window each

This commit is contained in:
Ian Gulliver
2026-10-18 20:58:56 -07:00
parent c0b2f38584
commit 4347d4e9e4
15 changed files with 456 additions and 209 deletions
+14 -13
View File
@@ -41,7 +41,7 @@ SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@An
| --- | --- |
| `API.swift`, `EventStream.swift` | The generated client over `URLSession`; the SSE client |
| `Store.swift`, `Feed.swift` | The one `@Observable` store, `handleEvent`, per-task feeds, focus |
| `ByreApp.swift`, `Startup.swift` | The `App`, window, menus, startup sequence, identity banner |
| `ByreApp.swift`, `Startup.swift` | The `App`, menus, the saved servers and their connections (`Servers`, `Connection`), server windows, the connection screen, startup sequence |
| `Panes.swift`, `PaneFocus.swift` | The two panes, tab strips, More menu, pane-focus reporting |
| `Chat.swift`, `TranscriptBuilder.swift`, `Composer.swift`, `MergeDialog.swift` | Transcript and composer for tasks and the lead |
| `Markdown.swift`, `MarkdownView.swift` | byre's Markdown parser to a block tree, and its views |
@@ -64,7 +64,7 @@ SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@An
## Store and events
`Store` holds me, projects, tasks by project, sessions by task, inbox, selected task, focus, lead sessions, summary reads and the connection: `handleEvent` with a `switch` on event kinds, `taskState`, `setFocus` with an optimistic apply and a latest-request-wins guard (`claimRequest`), `loadTasks` creating a feed for each active task, and `dropClosedFeeds`.
There is one `Store` per connected server. `Store` holds me, projects, tasks by project, sessions by task, inbox, selected task, focus, lead sessions, summary reads and the connection: `handleEvent` with a `switch` on event kinds, `taskState`, `setFocus` with an optimistic apply and a latest-request-wins guard (`claimRequest`), `loadTasks` creating a feed for each active task, and `dropClosedFeeds`.
`Feed` holds one task's events: history paged from `GET /api/tasks/{taskId}/events` 1000 at a time, live events held back until the history is in, duplicates dropped by `seq`, unstored `agent.delta` events passed straight to the streaming view only while the task is selected, and events that arrive together delivered as one batch. The lead's feed starts from the last 50 turns of `GET /api/me/lead/events` and pages back with `loadOlderLead`.
@@ -74,7 +74,7 @@ API calls go through the generated `Client` over `URLSessionTransport`. A middle
`EventStream` is a hand-written SSE client over `URLSession.bytes(for:)`. It splits lines itself, because `AsyncBytes.lines` drops the blank lines that end SSE events, and parses off the main actor, handing each frame to it. It handles `id`, `event: ping`, `event: ready` and `data`: it tracks the last seq, reconnects from it after a stale threshold (40 s, checked every 5 s) and a retry delay (2 s), when the network comes back (`NWPathMonitor`), and when the app becomes active with a stale stream. An event whose data doesn't decode is logged and skipped. There are two streams, global (after the bootstrap `last_event_seq`) and lead (`?lead=true`, after the last event of the lead's first page), and only the global one drives the connection dot.
Startup: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: <error>`; Change Server… leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created` and `notifyTurn` on `turn.ended`.
Startup: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: <error>`; connecting to the same URL again from New Server Window restarts it, and Forget Server leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created` and `notifyTurn` on `turn.ended`.
The app logs through `os.Logger` with subsystem `run.fc.byre.mac`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it.
@@ -82,20 +82,22 @@ Focus lives in `Store`. In-app `#/` links go through `Store.route`: `#/task/<id>
## Window and layout
One window. 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. 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 4-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.
- **Left pane:** tabs Lead, Status, Server, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip.
- **Right pane:** the task bar (task picker `<project> ▸ <title> ▾` and the preview links), then the task's tabs in the server's focus order, hiding Questions, Reviews and Workflow when they have nothing, with a **More ▾** menu for tabs that don't fit, carrying their badges. The pane's accent is the selected task's colour.
Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, not system sheets or alerts. The switcher and the Discard confirmation sit just below their anchor; the merge dialog is centred in the right pane.
Overlays live in `Overlays` (`Dialogs.swift`), one environment object for the window. An open overlay takes Escape (closing the topmost: alert, modal, image, switcher step or switcher, More menu) and Enter (the alert's OK, the modal's last button when its `canSubmit` holds, the switcher's highlighted row) through a local key monitor, which passes Enter on while an input method holds marked text. The switcher also takes ↑ and ↓ there. The left pane's selected tab lives in `Overlays` too, so ⌘L can switch to Lead and focus its composer.
Overlays live in `Overlays` (`Dialogs.swift`), one environment object for each window. An open overlay takes Escape (closing the topmost: alert, modal, image, switcher step or switcher, More menu) and Enter (the alert's OK, the modal's last button when its `canSubmit` holds, the switcher's highlighted row) through a local key monitor, which handles only key events for its own window and passes Enter on while an input method holds marked text. The switcher also takes ↑ and ↓ there. The left pane's selected tab lives in `Overlays` too, so ⌘L can switch to Lead and focus its composer.
Closing the window leaves the app running with its store and streams (`applicationShouldTerminateAfterLastWindowClosed` returns false), so notifications keep coming; clicking the Dock icon shows the window again. Quit flushes drafts and reports pane focus false before exiting.
Connections belong to `Servers`, not to windows: closing a server's window leaves its store and streams running (and the app running after the last window, `applicationShouldTerminateAfterLastWindowClosed` returns false), so its notifications keep coming. Clicking the Dock icon with no window showing opens every saved server's window again (`applicationShouldHandleReopen`), and File → Open Server opens one. Quit flushes every server's drafts and reports pane focus false before exiting.
## Views
- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, question bubbles that follow their item's `inbox.updated` events and open it in the Questions tab when clicked, and notes for the other event kinds. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard.
- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, question bubbles that follow their item's `inbox.updated` events and open it in the Questions tab when clicked, and notes for the other event kinds. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and its server's window is the key window of the active app (`PaneFocus.windowKey`, `markSummaryRead`), checked again whenever the app activates or a window becomes key. The head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard.
- **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered to the live session's vendor), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images become thumbnails with ×, uploaded to `POST /api/blobs` on send; any other image `NSBitmapImageRep` decodes, such as TIFF, is converted to PNG first, its size checked after conversion. A failed send puts the text and images back and shows the error.
- **Questions.** The question a transcript bubble picked (`Store.question`), read-only once it is no longer open, or else the selected task's oldest open item, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide and Other…. Arrow keys move between choices. A 409 shows its error. The Changes summary line sits above it.
- **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Including the pinned-snapshot stepper, open-diff memory, per-task place in Files, finding diffs with Fix and Ignore, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text.
@@ -135,7 +137,7 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima
## Notifications
`Notifications.swift` posts through `UNUserNotificationCenter`: the preferences from `GET /api/me`, the events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · <task title>`, body the first line cut at 200 characters, and the item id or `turn-<seq>` as the request identifier, so a repeat replaces the earlier one. They show as banners even while byre is the active app (`Notifier`, the centre's delegate, set at launch). Clicking one activates the app, shows the window (reopening it if it was closed) and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; `Notifier` holds its task until startup has a store, then focuses it. Settings shows the authorization state (`This Mac shows notifications while byre is running.`, `This Mac doesn't show notifications from byre.` with a **Turn on for this Mac** button, which asks macOS for permission, or `Notifications are blocked for byre in System Settings.`). Nothing notifies while the app isn't running.
`Notifications.swift` posts through `UNUserNotificationCenter`: the preferences from `GET /api/me`, the events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · <task title>`, body the first line cut at 200 characters, `task_id` and `origin` (the server's) in its user info, and the item id, `turn-<seq>@<origin>` or `error-<seq>@<origin>` as the request identifier, so a repeat replaces the earlier one and two servers' event seqs don't collide. Every connected server notifies, its window open or not. They show as banners even while byre is the active app (`Notifier`, the centre's delegate, set at launch). Clicking one activates the app, opens or brings forward its server's window and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; `Notifier` holds its server and task until that server's startup has a store, then focuses it. Settings shows the authorization state (`This Mac shows notifications while byre is running.`, `This Mac doesn't show notifications from byre.` with a **Turn on for this Mac** button, which asks macOS for permission, or `Notifications are blocked for byre in System Settings.`). Nothing notifies while the app isn't running.
## Pane focus
@@ -143,15 +145,15 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima
## Drafts
`Drafts.swift` keeps drafts in a SwiftData `DraftRecord` (see "Persistence"), one per server and login: the record's `scope` is the server origin, normalized to lower-case scheme and host with the port always written, then the login: `<origin>.<login>`, for example `https://byre.example.ts.net:443.you@example.com`. Its `json` holds composer text by `message:<task id>` or `message:lead`, in-flight sends folded back in, checked choices and Other text by inbox item, pruned when a task closes or an item resolves, saved 400 ms after a change and at once on blur, send and quit. A flush writes only after a local change, so a Mac that loaded its drafts before another Mac's arrived doesn't overwrite them by quitting. Each finished CloudKit import reloads the composer texts and answers from the record when there is no unflushed local change and no send in flight; a composer already on screen keeps the text it showed, and picks up the new text the next time it appears. Two Macs editing drafts for the same scope within one sync of each other keep whichever flushed last. Change Server… leaves the old server's drafts in place, and switching back finds them. Pasted images aren't kept; after a relaunch the composer says how many need re-attaching.
`Drafts.swift` keeps drafts in a SwiftData `DraftRecord` (see "Persistence"), one per server and login: the record's `scope` is the server origin, normalized to lower-case scheme and host with the port always written, then the login: `<origin>.<login>`, for example `https://byre.example.ts.net:443.you@example.com`. Its `json` holds composer text by `message:<task id>` or `message:lead`, in-flight sends folded back in, checked choices and Other text by inbox item, pruned when a task closes or an item resolves, saved 400 ms after a change and at once on blur, send and quit. A flush writes only after a local change, so a Mac that loaded its drafts before another Mac's arrived doesn't overwrite them by quitting. Each finished CloudKit import reloads the composer texts and answers from the record when there is no unflushed local change and no send in flight; a composer already on screen keeps the text it showed, and picks up the new text the next time it appears. Two Macs editing drafts for the same scope within one sync of each other keep whichever flushed last. Forget Server leaves the server's drafts in place, and connecting to it again finds them. Pasted images aren't kept; after a relaunch the composer says how many need re-attaching.
## Keyboard and menus
The app menu has About, Check for Updates…, Change Server…, Hide, Hide Others and Quit; 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. There is no File menu. The Go commands are disabled while an 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 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.
## Connection
On first launch the window shows one field, Server URL (an `https` origin with no path), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `Contents/Helpers/byre install` through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`; it takes the address from the `byre is at` line, polls `GET /api/admin/status` for up to 10 s while the service starts, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is kept as the app's one `ServerRecord` (see "Persistence") and startup runs; later launches start against it directly. While the connection screen shows with an empty field, each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) looks for a `ServerRecord` again and starts against it, so on a new Mac the address another Mac saved connects without typing.
On first launch, and in a window from New Server Window, the window shows one field, Server URL (an `https` origin with no path), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `Contents/Helpers/byre install` through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`; it takes the address from the `byre is at` line, polls `GET /api/admin/status` for up to 10 s while the service starts, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is saved as a `ServerRecord` (see "Persistence") with the first task colour no other saved server uses, startup runs, and the window becomes that server's; later launches start against every saved server directly. Connecting to a server whose startup failed replaces its failed connection with a new one, keeping its record and colour. Connecting to a server that already has a window brings that window forward and closes this one, so each server has one window and one `Store`; worktree images (`ImageCache`) and review patches (keyed by origin) are cached per server. Each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) reloads the saved servers: it starts a connection for each new one and stops the connection of each one forgotten on another Mac, and a window showing the connection screen with an empty field takes the first new one, so on a new Mac the address another Mac saved connects without typing. Forget Server deletes the window's `ServerRecord` (on every Mac, through iCloud), stops its connection and shows the connection screen in that window with the old origin in the field.
## Updates
@@ -159,7 +161,7 @@ On first launch the window shows one field, Server URL (an `https` origin with n
## Persistence
`Persistence.swift` holds the app's state in SwiftData, synced through CloudKit to the private database of container `iCloud.run.fc.byre.mac`, so it follows the iCloud account to every Mac: `ServerRecord` (the server origin), `DraftRecord` (drafts, see "Drafts") and `VisitRecord` (one per task: when it was last selected, read by the task switcher). CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the newest `ServerRecord` and `DraftRecord` and the latest `VisitRecord` per task, and the next write keeps one and deletes the rest. The store is `byre.store` in `run.fc.byre.mac` under Application Support (the app isn't sandboxed, so SwiftData's default `default.store` would be shared with every other unsandboxed app). It opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. Change Server… (in the app menu) flushes drafts, reports pane focus false, drops the store and streams, and shows the connection screen again, its field holding the current origin. Quitting does the same flush and report, waiting up to 2 s for the report before exiting (`applicationShouldTerminate` in `ByreApp.swift`).
`Persistence.swift` holds the app's state in SwiftData, synced through CloudKit to the private database of container `iCloud.run.fc.byre.mac`, so it follows the iCloud account to every Mac: `ServerRecord` (one per saved server: its origin and `color`, an index into the task colours), `DraftRecord` (drafts, see "Drafts") and `VisitRecord` (one per task: when it was last selected, read by the task switcher). CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the oldest `ServerRecord` per origin, the newest `DraftRecord` and the latest `VisitRecord` per task; the next draft or visit write keeps one and deletes the rest, and Forget Server deletes every record for its origin. The store is `byre.store` in `run.fc.byre.mac` under Application Support (the app isn't sandboxed, so SwiftData's default `default.store` would be shared with every other unsandboxed app). It opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. Forget Server (in the app menu) flushes that server's drafts, reports pane focus false, drops its store and streams, and shows the connection screen again, its field holding the forgotten origin. Quitting does the same flush and report for every server at once, waiting up to 2 s for the reports before exiting (`applicationShouldTerminate` in `ByreApp.swift`).
## Gaps in SwiftUI
@@ -184,4 +186,3 @@ The font files need one thing beyond loading. The latin and latin-ext files are
## Not built
- A Dock badge, a `byre:` URL scheme, Quick Look, a closed-task browser, editing files.
- Several servers or windows.
+5 -5
View File
File diff suppressed because one or more lines are too long
+49 -18
View File
@@ -9,7 +9,6 @@ typealias ViewState = State
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
var connection: Connection?
private var quitting = false
func applicationDidFinishLaunching(_ notification: Notification) {
@@ -21,8 +20,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
false
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
if flag {
return true
}
Servers.shared.openAll()
return false
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard let store = connection?.store, !quitting else {
let stores = Servers.shared.connections.values.compactMap(\.store)
guard !stores.isEmpty, !quitting else {
return .terminateNow
}
quitting = true
@@ -34,7 +42,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
Task {
await withTaskGroup(of: Void.self) { group in
for store in stores {
group.addTask {
await store.stop()
}
}
}
reply()
}
Task {
@@ -48,29 +62,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@main
struct ByreApp: App {
@NSApplicationDelegateAdaptor private var delegate: AppDelegate
@ViewState private var connection = Connection()
@ViewState private var overlays = Overlays()
var body: some Scene {
Window("byre", id: "main") {
RootView()
.environment(connection)
.environment(overlays)
.font(Fonts.font(13))
.frame(minWidth: 640, minHeight: 400)
.onAppear {
delegate.connection = connection
}
WindowGroup(id: "main", for: String.self) { $origin in
ServerWindow(origin: $origin)
}
.defaultSize(width: 1280, height: 820)
.restorationBehavior(.disabled)
.defaultLaunchBehavior(.presented)
.commands {
ByreCommands()
}
}
}
struct ByreCommands: Commands {
@FocusedValue(\.serverWindow) private var focus
@Environment(\.openWindow) private var openWindow
var body: some Commands {
CommandGroup(after: .appInfo) {
Button("Check for Updates…") {
Updater.shared.check()
}
Button("Change Server…") {
connection.changeServer()
Button("Forget Server") {
focus?.forget?()
}
.disabled(focus?.forget == nil)
}
CommandGroup(replacing: .newItem) {
Button("New Server Window") {
openWindow(id: "main")
}
.keyboardShortcut("n")
Menu("Open Server") {
ForEach(Servers.shared.origins, id: \.self) { origin in
Button(URL(string: origin)?.host() ?? origin) {
openWindow(id: "main", value: origin)
}
}
}
.disabled(Servers.shared.origins.isEmpty)
}
PasteboardCommands()
CommandMenu("Go") {
@@ -82,15 +114,14 @@ struct ByreApp: App {
goButton("Keyboard Shortcuts", "/")
}
}
}
private func goButton(_ label: String, _ key: String) -> some View {
Button(label) {
if let store = connection.store {
if let store = focus?.store, let overlays = focus?.overlays {
runGo(key, store, overlays)
}
}
.keyboardShortcut(KeyEquivalent(Character(key)))
.disabled(connection.store == nil || overlays.dialogOpen)
.disabled(focus?.store == nil || focus?.overlays.dialogOpen == true)
}
}
+13 -11
View File
@@ -22,11 +22,10 @@ private let emptyText: [ChangeMode: String] = [
.step: "This step changed nothing.",
]
@MainActor
private enum ChangesMemory {
static var open: Set<String> = []
static var range = ChangeMode.main
static var pinRange = ChangeMode.step
struct ChangesMemory {
var open: Set<String> = []
var range = ChangeMode.main
var pinRange = ChangeMode.step
}
func untilCancelled() async {
@@ -43,9 +42,9 @@ final class ChangesModel {
private(set) var changes: TaskChanges?
private(set) var error = ""
private(set) var diffs: [String: Result<String, ApiError>] = [:]
private(set) var open = ChangesMemory.open
private(set) var range = ChangesMemory.range
private(set) var pinRange = ChangesMemory.pinRange
private(set) var open: Set<String>
private(set) var range: ChangeMode
private(set) var pinRange: ChangeMode
@ObservationIgnored private var loading = false
@ObservationIgnored private var again = false
@ObservationIgnored private var timer: Task<Void, Never>?
@@ -56,6 +55,9 @@ final class ChangesModel {
init(_ store: Store, compact: Bool) {
self.store = store
self.compact = compact
open = store.changesMemory.open
range = store.changesMemory.range
pinRange = store.changesMemory.pinRange
}
var pinned: Int? {
@@ -161,7 +163,7 @@ final class ChangesModel {
return
}
range = r
ChangesMemory.range = r
store.changesMemory.range = r
retarget()
}
@@ -170,7 +172,7 @@ final class ChangesModel {
return
}
pinRange = r
ChangesMemory.pinRange = r
store.changesMemory.pinRange = r
retarget()
}
@@ -180,7 +182,7 @@ final class ChangesModel {
} else {
open.remove(path)
}
ChangesMemory.open = open
store.changesMemory.open = open
}
func shows(_ a: LineAnchor) -> Bool {
+9 -1
View File
@@ -26,6 +26,7 @@ final class ChatModel {
@ObservationIgnored private var focusStart = true
@ObservationIgnored private var visibleSummaries: Set<Int64> = []
@ObservationIgnored private var activeObserver: NotificationCenter.ObservationToken?
@ObservationIgnored private var keyObserver: NotificationCenter.ObservationToken?
@ObservationIgnored private var started = false
init(_ kind: ChatKind, _ store: Store) {
@@ -150,6 +151,9 @@ final class ChatModel {
activeObserver = NotificationCenter.default.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in
self?.markShown()
}
keyObserver = NotificationCenter.default.addObserver(of: NSWindow.self, for: .didBecomeKey) { [weak self] _ in
self?.markShown()
}
}
func stop() {
@@ -161,6 +165,10 @@ final class ChatModel {
NotificationCenter.default.removeObserver(activeObserver)
}
activeObserver = nil
if let keyObserver {
NotificationCenter.default.removeObserver(keyObserver)
}
keyObserver = nil
unmergedTimer?.cancel()
unmergedTimer = nil
builder.stop()
@@ -214,7 +222,7 @@ final class ChatModel {
}
private func markShown() {
guard let taskId, NSApp.isActive else {
guard let taskId, store.paneFocus.windowKey else {
return
}
for seq in visibleSummaries {
+8 -2
View File
@@ -57,6 +57,7 @@ final class Overlays {
@ObservationIgnored var pickerFrame: CGRect = .zero
@ObservationIgnored var menuHover = false
var paneFrame: CGRect = .zero
@ObservationIgnored weak var window: NSWindow?
@ObservationIgnored private var monitor: Any?
@ObservationIgnored private var mouseMonitor: Any?
@@ -134,17 +135,22 @@ final class Overlays {
return
}
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
let win = event.window.map(ObjectIdentifier.init)
let code = event.keyCode
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let chars = event.charactersIgnoringModifiers ?? ""
let taken = MainActor.assumeIsolated {
self?.key(code, flags, chars, ctrl) ?? false
guard let self, let window = self.window, win == ObjectIdentifier(window) else {
return false
}
return self.key(code, flags, chars, ctrl)
}
return taken ? nil : event
}
mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in
let win = event.window.map(ObjectIdentifier.init)
MainActor.assumeIsolated {
if let self, self.menu != nil, !self.menuHover {
if let self, let window = self.window, win == ObjectIdentifier(window), self.menu != nil, !self.menuHover {
self.menu = nil
}
}
+2 -3
View File
@@ -38,7 +38,6 @@ func worktreeImageSource(_ path: String, _ taskId: String?, dir: String = "") ->
@MainActor
final class ImageCache {
static let shared = ImageCache()
private var done: [ImageSource: NSImage] = [:]
private var loading: [ImageSource: Task<NSImage, any Error>] = [:]
@@ -99,7 +98,7 @@ struct ByreImage: View {
var body: some View {
Group {
if let image = image ?? ImageCache.shared.cached(source) {
if let image = image ?? store.imageCache.cached(source) {
Image(nsImage: image)
.resizable()
.scaledToFit()
@@ -122,7 +121,7 @@ struct ByreImage: View {
return
}
do {
image = try await ImageCache.shared.load(source, store.api)
image = try await store.imageCache.load(source, store.api)
} catch {
log.error("load image \(src, privacy: .public): \(errorText(error), privacy: .public)")
failed = true
+15 -15
View File
@@ -4,28 +4,26 @@ import UserNotifications
@MainActor
final class Notifier: NSObject, UNUserNotificationCenterDelegate {
static let shared = Notifier()
weak var connection: Connection?
var showWindow: (() -> Void)?
private var pending: String?
private var pending: (origin: String, taskId: String)?
func install() {
UNUserNotificationCenter.current().delegate = self
}
private func open(_ taskId: String) {
private func open(_ origin: String, _ taskId: String) {
NSApp.activate()
showWindow?()
pending = taskId
Servers.shared.openWindow?(origin)
pending = (origin, taskId)
deliver()
}
func deliver() {
guard let taskId = pending, let store = connection?.store else {
guard let p = pending, let store = Servers.shared.connections[p.origin]?.store else {
return
}
pending = nil
if !taskId.isEmpty {
store.focusTask(taskId)
if !p.taskId.isEmpty {
store.focusTask(p.taskId)
}
}
@@ -34,9 +32,11 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate {
}
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
let taskId = response.notification.request.content.userInfo["task_id"] as? String ?? ""
let info = response.notification.request.content.userInfo
let origin = info["origin"] as? String ?? ""
let taskId = info["task_id"] as? String ?? ""
await MainActor.run {
open(taskId)
open(origin, taskId)
}
}
}
@@ -60,7 +60,7 @@ private func show(_ store: Store, _ taskId: String, _ tag: String, _ text: Strin
content.title = store.findTask(taskId).map { "byre · \($0.title)" } ?? "byre"
content.body = line.count > 200 ? "\(line.prefix(199))…" : line
content.sound = .default
content.userInfo = ["task_id": taskId]
content.userInfo = ["task_id": taskId, "origin": store.api.origin.absoluteString]
let request = UNNotificationRequest(identifier: tag, content: content, trigger: nil)
enqueue {
let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
@@ -92,7 +92,7 @@ func notifyError(_ store: Store, _ ev: ByreEvent) {
if !(limited ? me.notificationPrefs.rateLimits : me.notificationPrefs.errors) {
return
}
show(store, taskId, "error-\(ev.seq)", limited ? "Rate limited" : "Error: \(str(ev.payload.value, "error") ?? "")")
show(store, taskId, "error-\(ev.seq)@\(store.api.origin.absoluteString)", limited ? "Rate limited" : "Error: \(str(ev.payload.value, "error") ?? "")")
}
@MainActor
@@ -109,7 +109,7 @@ func clearInbox(_ item: InboxItem) {
func clearTurns(_ taskId: String, through seq: Int64) {
enqueue {
let ids = await UNUserNotificationCenter.current().deliveredNotifications().map(\.request).filter { r in
guard r.content.userInfo["task_id"] as? String == taskId, r.identifier.hasPrefix("turn-"), let s = Int64(r.identifier.dropFirst(5)) else {
guard r.content.userInfo["task_id"] as? String == taskId, r.identifier.hasPrefix("turn-"), let s = Int64(r.identifier.dropFirst(5).prefix { $0 != "@" }) else {
return false
}
return s <= seq
@@ -123,5 +123,5 @@ func notifyTurn(_ store: Store, _ ev: ByreEvent) {
guard let me = store.me, me.notificationPrefs.reports, ev.payload.value["summary"] as? Bool == true, let taskId = ev.taskId, !taskId.isEmpty else {
return
}
show(store, taskId, "turn-\(ev.seq)", str(ev.payload.value, "result") ?? "")
show(store, taskId, "turn-\(ev.seq)@\(store.api.origin.absoluteString)", str(ev.payload.value, "result") ?? "")
}
+4
View File
@@ -72,6 +72,10 @@ final class PaneFocus {
window = nil
}
var windowKey: Bool {
(window?.isKeyWindow ?? false) && NSApp.isActive
}
func setFrame(_ frame: CGRect) {
paneFrame = frame
}
+1
View File
@@ -70,6 +70,7 @@ struct MainView: View {
})
.background {
WindowReader { window in
overlays.window = window
store.paneFocus.attach(window)
}
}
+3 -1
View File
@@ -5,9 +5,11 @@ import SwiftData
final class ServerRecord {
var origin = ""
var saved = Date.distantPast
var color = 0
init(origin: String) {
init(origin: String, color: Int) {
self.origin = origin
self.color = color
saved = Date()
}
}
+2 -2
View File
@@ -20,12 +20,12 @@ private enum SnapshotPatches {
static var patches: [String: Task<String?, any Error>] = [:]
static func patch(_ api: API, _ taskId: String, _ n: Int, _ path: String) async throws -> String? {
let key = "\(taskId)/\(n)/\(path)"
let key = "\(api.origin.absoluteString)/\(taskId)/\(n)/\(path)"
if let cached = patches[key] {
return try await cached.value
}
let fetched = Task<String?, any Error> {
let ck = "\(taskId)/\(n)"
let ck = "\(api.origin.absoluteString)/\(taskId)/\(n)"
let pending = changes[ck] ?? Task {
try await api.client.getTaskChanges(path: .init(taskId: taskId), query: .init(snapshot: n)).ok.body.json
}
+30
View File
@@ -22,6 +22,11 @@ struct SettingsView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
heading("Window colour")
Text("Windows for this server have a border in this colour, on every Mac you use.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
ServerColorPicker()
heading("Notifications")
DeviceNotifications()
PrefsBox()
@@ -53,6 +58,31 @@ struct SettingsView: View {
}
}
private struct ServerColorPicker: View {
@Environment(Store.self) private var store
var body: some View {
let origin = store.api.origin.absoluteString
let current = Servers.shared.record(origin)?.color
HStack(spacing: 4) {
ForEach(taskPalette.indices, id: \.self) { i in
Button {
Servers.shared.setColor(origin, i)
} label: {
Circle()
.fill(taskPalette[i])
.frame(width: 18, height: 18)
.padding(3)
.overlay(Circle().stroke(i == current ? Palette.text : .clear, lineWidth: 1.5))
.contentShape(Circle())
}
.buttonStyle(.plain)
}
}
.padding(.top, 8)
}
}
private struct DeviceNotifications: View {
@ViewState private var status: UNAuthorizationStatus?
@ViewState private var asking = false
+278 -117
View File
@@ -4,49 +4,119 @@ import Observation
import SwiftData
import SwiftUI
@MainActor
@Observable
final class Servers {
static let shared = Servers()
private(set) var entries: [(origin: String, record: ServerRecord)] = []
private(set) var connections: [String: Connection] = [:]
@ObservationIgnored var shown: Set<String> = []
@ObservationIgnored var launched = false
@ObservationIgnored var openWindow: ((String?) -> Void)?
private init() {
load()
Task {
for await note in NotificationCenter.default.notifications(named: NSPersistentCloudKitContainer.eventChangedNotification) {
if let event = note.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event, event.type == .import, event.endDate != nil {
for connection in connections.values {
connection.store?.drafts?.reload()
}
load()
}
}
}
}
var origins: [String] {
entries.map(\.origin)
}
func record(_ origin: String) -> ServerRecord? {
entries.first { $0.origin == origin }?.record
}
func color(_ origin: String) -> Color {
taskPalette[(record(origin)?.color ?? 0) % taskPalette.count]
}
private func load() {
var loaded: [(origin: String, record: ServerRecord)] = []
for record in fetchRecords(FetchDescriptor<ServerRecord>(sortBy: [SortDescriptor(\.saved)])) {
if let origin = parseOrigin(record.origin), !loaded.contains(where: { $0.origin == origin.absoluteString }) {
loaded.append((origin.absoluteString, record))
}
}
entries = loaded
for (origin, _) in loaded where connections[origin] == nil {
connections[origin] = Connection(origin: parseOrigin(origin)!)
}
for (origin, connection) in connections where !loaded.contains(where: { $0.origin == origin }) {
connections[origin] = nil
Task {
await connection.stop()
}
}
}
func add(_ origin: URL) {
if let connection = connections[origin.absoluteString], case .failed = connection.phase {
connections[origin.absoluteString] = nil
}
if record(origin.absoluteString) == nil {
let used = Set(entries.map(\.record.color))
let color = taskPalette.indices.first { !used.contains($0) } ?? entries.count % taskPalette.count
persistence.insert(ServerRecord(origin: origin.absoluteString, color: color))
saveRecords()
}
load()
}
func remove(_ origin: String) {
for record in fetchRecords(FetchDescriptor<ServerRecord>()) where parseOrigin(record.origin)?.absoluteString == origin {
persistence.delete(record)
}
saveRecords()
load()
}
func setColor(_ origin: String, _ color: Int) {
for record in fetchRecords(FetchDescriptor<ServerRecord>()) where parseOrigin(record.origin)?.absoluteString == origin {
record.color = color
}
saveRecords()
}
func openAll() {
if origins.isEmpty {
openWindow?(nil)
}
for origin in origins {
openWindow?(origin)
}
}
}
@MainActor
@Observable
final class Connection {
enum Phase {
case connect
case starting
case failed(String)
case running(Store)
}
var phase: Phase = .connect
var serverText: String
var problem: String?
var connecting = false
@ObservationIgnored private var generation = 0
let origin: URL
var phase: Phase = .starting
@ObservationIgnored private var stopped = false
init() {
serverText = ""
resume()
init(origin: URL) {
self.origin = origin
Task {
for await note in NotificationCenter.default.notifications(named: NSPersistentCloudKitContainer.eventChangedNotification) {
if let event = note.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event, event.type == .import, event.endDate != nil {
store?.drafts?.reload()
resume()
await start()
}
}
}
}
private func resume() {
guard case .connect = phase, serverText.isEmpty, !connecting, let saved = Self.servers().first?.origin, let origin = parseOrigin(saved) else {
return
}
serverText = saved
phase = .starting
Task {
await start(origin)
}
}
private static func servers() -> [ServerRecord] {
fetchRecords(FetchDescriptor(sortBy: [SortDescriptor(\.saved, order: .reverse)]))
}
var store: Store? {
if case .running(let store) = phase {
@@ -55,56 +125,84 @@ final class Connection {
return nil
}
func connect() async {
func stop() async {
stopped = true
if let store {
await store.stop()
}
}
private func start() async {
let store = Store(api: API(origin: origin))
do {
let status = try await store.api.client.getStatus().ok.body.json
async let me: Void = store.loadMe()
async let projects: Void = store.loadProjects()
async let inbox: Void = store.loadInbox()
async let lead: Void = store.loadLeadSessions()
async let reads: Void = store.loadSummaryReads()
_ = try await (me, projects, inbox, lead, reads)
store.loadDrafts()
try await store.loadFocus()
guard !stopped else {
await store.stop()
return
}
Task {
await store.startLeadStream()
}
phase = .running(store)
store.startGlobalStream(after: status.lastEventSeq)
store.remote.start()
Notifier.shared.deliver()
} catch {
await store.stop()
if !stopped {
phase = .failed("byre failed to start: \(errorText(error))")
}
}
}
}
@MainActor
@Observable
final class ConnectForm {
var serverText = ""
var problem: String?
var connecting = false
func connect() async -> URL? {
guard let origin = parseOrigin(serverText) else {
problem = "The server URL must be an https origin with no path, such as https://byre.example.ts.net."
return
return nil
}
connecting = true
problem = nil
let gen = generation
do {
_ = try await API(origin: origin).client.getStatus().ok
} catch {
if gen == generation {
connecting = false
problem = explain(error, origin)
}
return
}
guard gen == generation else {
return
return nil
}
connecting = false
for server in Self.servers() {
persistence.delete(server)
}
persistence.insert(ServerRecord(origin: origin.absoluteString))
saveRecords()
serverText = origin.absoluteString
phase = .starting
await start(origin)
Servers.shared.add(origin)
return origin
}
func installHere() async {
func installHere() async -> URL? {
connecting = true
problem = nil
let gen = generation
do {
let origin = try await Server.turnOn()
serverText = origin.absoluteString
try await waitForServer(origin)
} catch {
if gen == generation {
connecting = false
problem = errorText(error)
return nil
}
return
}
guard gen == generation else {
return
}
await connect()
return await connect()
}
private func waitForServer(_ origin: URL) async throws {
@@ -125,51 +223,6 @@ final class Connection {
try await group.next()
}
}
func changeServer() {
generation += 1
if let store {
Task {
await store.stop()
}
}
connecting = false
problem = nil
phase = .connect
}
private func start(_ origin: URL) async {
generation += 1
let gen = generation
let store = Store(api: API(origin: origin))
do {
let status = try await store.api.client.getStatus().ok.body.json
async let me: Void = store.loadMe()
async let projects: Void = store.loadProjects()
async let inbox: Void = store.loadInbox()
async let lead: Void = store.loadLeadSessions()
async let reads: Void = store.loadSummaryReads()
_ = try await (me, projects, inbox, lead, reads)
store.loadDrafts()
try await store.loadFocus()
guard gen == generation else {
await store.stop()
return
}
Task {
await store.startLeadStream()
}
phase = .running(store)
store.startGlobalStream(after: status.lastEventSeq)
store.remote.start()
Notifier.shared.deliver()
} catch {
await store.stop()
if gen == generation {
phase = .failed("byre failed to start: \(errorText(error))")
}
}
}
}
private let tlsErrors: Set<URLError.Code> = [
@@ -207,20 +260,21 @@ func explain(_ error: any Error, _ origin: URL) -> String {
}
struct ConnectView: View {
@Bindable var connection: Connection
@Bindable var form: ConnectForm
let connected: (URL) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Server URL")
TextField("https://byre.example.ts.net", text: $connection.serverText)
TextField("https://byre.example.ts.net", text: $form.serverText)
.textFieldStyle(.roundedBorder)
.onSubmit(connect)
HStack(spacing: 10) {
Button("Connect", action: connect)
Button("Run byre on this Mac", action: installHere)
}
.disabled(connection.connecting)
if let problem = connection.problem {
.disabled(form.connecting)
if let problem = form.problem {
Text(problem)
.foregroundStyle(Palette.danger)
.textSelection(.enabled)
@@ -233,13 +287,17 @@ struct ConnectView: View {
private func connect() {
Task {
await connection.connect()
if let origin = await form.connect() {
connected(origin)
}
}
}
private func installHere() {
Task {
await connection.installHere()
if let origin = await form.installHere() {
connected(origin)
}
}
}
}
@@ -256,15 +314,43 @@ struct FatalView: View {
}
}
struct RootView: View {
@Environment(Connection.self) private var connection
@Environment(\.openWindow) private var openWindow
struct ServerFocus {
let store: Store?
let overlays: Overlays
let forget: (() -> Void)?
}
var body: some View {
Group {
extension FocusedValues {
@Entry var serverWindow: ServerFocus?
}
struct ServerWindow: View {
@Binding var origin: String?
@ViewState private var overlays = Overlays()
@ViewState private var form = ConnectForm()
@Environment(\.openWindow) private var openWindow
@Environment(\.dismiss) private var dismissWindow
private var connection: Connection? {
origin.flatMap { Servers.shared.connections[$0] }
}
private var focus: ServerFocus {
var forgetting: (() -> Void)?
if connection != nil {
forgetting = forget
}
return ServerFocus(store: connection?.store, overlays: overlays, forget: forgetting)
}
private var frameColor: Color {
connection.map { Servers.shared.color($0.origin.absoluteString) } ?? Palette.bg
}
@ViewBuilder
private var content: some View {
if let connection {
switch connection.phase {
case .connect:
ConnectView(connection: connection)
case .starting:
Color.clear
case .failed(let message):
@@ -273,17 +359,92 @@ struct RootView: View {
MainView()
.environment(store)
}
} else {
ConnectView(form: form) { connected in
let key = connected.absoluteString
if Servers.shared.shown.contains(key) {
openWindow(id: "main", value: key)
dismissWindow()
return
}
origin = key
}
}
}
private var framed: some View {
content
.foregroundStyle(Palette.text)
.tint(Palette.accent)
.background(Palette.bg)
.onAppear {
.padding(connection == nil ? 0 : 4)
.background(frameColor)
.environment(overlays)
.font(Fonts.font(13))
.frame(minWidth: 640, minHeight: 400)
}
var body: some View {
framed
.navigationTitle(connection?.origin.host() ?? "byre")
.focusedSceneValue(\.serverWindow, focus)
.onChange(of: origin, initial: true) { old, new in
moved(old, new)
}
.onChange(of: Servers.shared.origins) { old, new in
adopt(new.filter { !old.contains($0) })
}
.onAppear(perform: appeared)
.onDisappear {
if let origin {
Servers.shared.shown.remove(origin)
}
}
}
private func moved(_ old: String?, _ new: String?) {
if let old {
Servers.shared.shown.remove(old)
}
if let new {
Servers.shared.shown.insert(new)
}
}
private func appeared() {
let servers = Servers.shared
let openWindow = openWindow
Notifier.shared.connection = connection
Notifier.shared.showWindow = {
servers.openWindow = { origin in
if let origin {
openWindow(id: "main", value: origin)
} else {
openWindow(id: "main")
}
}
if !servers.launched {
servers.launched = true
adopt(servers.origins)
for o in servers.origins where !servers.shown.contains(o) {
openWindow(id: "main", value: o)
}
}
Notifier.shared.deliver()
}
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
}
origin = next
Servers.shared.shown.insert(next)
}
private func forget() {
guard let current = origin else {
return
}
form.serverText = current
origin = nil
Servers.shared.remove(current)
}
}
+2
View File
@@ -50,6 +50,7 @@ final class Store {
var pickedMembers: [String: String] = [:]
private(set) var drafts: Drafts?
let images = ImageDrafts()
let imageCache = ImageCache()
let events = Listeners<ByreEvent>()
let paneFocus: PaneFocus
let remote: Remote
@@ -60,6 +61,7 @@ final class Store {
@ObservationIgnored private var globalStream: EventStream?
@ObservationIgnored private var leadStream: EventStream?
@ObservationIgnored private var stopped = false
@ObservationIgnored var changesMemory = ChangesMemory()
init(api: API) {
self.api = api