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
+62 -31
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 store.stop()
await withTaskGroup(of: Void.self) { group in
for store in stores {
group.addTask {
await store.stop()
}
}
}
reply()
}
Task {
@@ -48,49 +62,66 @@ 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 {
CommandGroup(after: .appInfo) {
Button("Check for Updates…") {
Updater.shared.check()
}
Button("Change Server…") {
connection.changeServer()
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("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)
}
}
}
PasteboardCommands()
CommandMenu("Go") {
goButton("Switch Task", "k")
goButton("Project", "p")
goButton("Lead", "l")
goButton("Merge", "s")
Divider()
goButton("Keyboard Shortcuts", "/")
}
.disabled(Servers.shared.origins.isEmpty)
}
PasteboardCommands()
CommandMenu("Go") {
goButton("Switch Task", "k")
goButton("Project", "p")
goButton("Lead", "l")
goButton("Merge", "s")
Divider()
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
+285 -124
View File
@@ -6,46 +6,116 @@ import SwiftUI
@MainActor
@Observable
final class Connection {
enum Phase {
case connect
case starting
case failed(String)
case running(Store)
}
final class Servers {
static let shared = Servers()
var phase: Phase = .connect
var serverText: String
var problem: String?
var connecting = false
@ObservationIgnored private var generation = 0
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)?
init() {
serverText = ""
resume()
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 {
store?.drafts?.reload()
resume()
for connection in connections.values {
connection.store?.drafts?.reload()
}
load()
}
}
}
}
private func resume() {
guard case .connect = phase, serverText.isEmpty, !connecting, let saved = Self.servers().first?.origin, let origin = parseOrigin(saved) else {
return
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))
}
}
serverText = saved
phase = .starting
Task {
await start(origin)
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()
}
}
}
private static func servers() -> [ServerRecord] {
fetchRecords(FetchDescriptor(sortBy: [SortDescriptor(\.saved, order: .reverse)]))
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 starting
case failed(String)
case running(Store)
}
let origin: URL
var phase: Phase = .starting
@ObservationIgnored private var stopped = false
init(origin: URL) {
self.origin = origin
Task {
await start()
}
}
var store: Store? {
@@ -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
connecting = false
problem = explain(error, origin)
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
connecting = false
problem = errorText(error)
return nil
}
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)
}
}
.foregroundStyle(Palette.text)
.tint(Palette.accent)
.background(Palette.bg)
.onAppear {
let openWindow = openWindow
Notifier.shared.connection = connection
Notifier.shared.showWindow = {
openWindow(id: "main")
} 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
}
Notifier.shared.deliver()
}
}
private var framed: some View {
content
.foregroundStyle(Palette.text)
.tint(Palette.accent)
.background(Palette.bg)
.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
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