server switcher is broken
This commit is contained in:
+1
-1
@@ -82,7 +82,7 @@ Focus lives in `Store`. In-app `#/` links go through `Store.route`: `#/task/<id>
|
|||||||
|
|
||||||
## Window and layout
|
## Window and layout
|
||||||
|
|
||||||
One window (a `Window` scene, id `main`) shows one saved server at a time: `Servers.current`, the origin it shows, or nil for the connection screen. The title bar holds no title, only the server picker (`ServerPicker`, a toolbar item in the principal placement): a row of every saved server in saved order (`ServerRecord.saved`, the order Previous and Next Server follow), each its colour dot (hollow until its connection is running) and short name (its host up to the first dot, `serverName`), then **+**, which shows the connection screen. Clicking a server shows it. Since every connection runs in the background, each server carries what needs the user there: its count of open inbox items as a badge, with the server's name in the warning colour and tint, and its count of active tasks with an unread turn summary (`Store.unreadSummaries`) as "N new". The current server's name is bold on the surface colour. Switching servers swaps the window's content; every connection keeps running in the background. Each server has its own `Overlays`, held by its `Connection`, so its switcher, dialogs and left-pane tab survive switching away and back, and its `MainView` is rebuilt (`.id(origin)`) so no view state carries from one server to another. The 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). The connection screen has 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.
|
One window (a `Window` scene, id `main`) shows one saved server at a time: `Servers.current`, the origin it shows, or nil for the connection screen. The title bar holds no title, only the server picker (`ServerPicker`, a toolbar item in the principal placement with the toolbar's shared glass background hidden, so the chips sit directly on the title bar): a row of every saved server in saved order (`ServerRecord.saved`, the order Previous and Next Server follow), each its colour dot (hollow until its connection is running) and short name (its host up to the first dot, `serverName`), then **+**, which shows the connection screen. Clicking a server shows it. Since every connection runs in the background, each server carries what needs the user there: its count of open inbox items as a badge, with the server's name in the warning colour and tint, and its count of active tasks with an unread turn summary (`Store.unreadSummaries`) as a bare number in the accent colour, in a slot two digits wide that is reserved even when the count is zero, so the picker never shifts as summaries arrive and are read. The current server's name is bold on the surface colour. Switching servers swaps the window's content; every connection keeps running in the background. Each server has its own `Overlays`, held by its `Connection`, so its switcher, dialogs and left-pane tab survive switching away and back, and its `MainView` is rebuilt (`.id` of its `Store`) so no view state carries from one server to another. A `Connection` remembers the `started_at` its server reported when it connected; whenever its global event stream reconnects, it reads `/api/admin/status` again, and when `started_at` has changed (the server restarted, perhaps on a new build whose migrations rewrote stored events), it builds a new `Store` that loads everything afresh, swaps it in once loaded, and stops the old one; the new `Store` takes over the old one's `Drafts` and `ImageDrafts`, so unsent composer text and pasted images survive, so no events cached from the old server are ever drawn. The 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). The connection screen has 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.
|
||||||
|
|
||||||
At launch the window shows the oldest saved server. A CloudKit import that brings a server saved on another Mac starts its connection, and the window takes it while it shows an empty connection screen. A CloudKit import that removes the current server switches to the first saved one. SwiftUI's own window restoration is off (`restorationBehavior(.disabled)`); the scene keeps the id `main` that earlier 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.
|
At launch the window shows the oldest saved server. A CloudKit import that brings a server saved on another Mac starts its connection, and the window takes it while it shows an empty connection screen. A CloudKit import that removes the current server switches to the first saved one. SwiftUI's own window restoration is off (`restorationBehavior(.disabled)`); the scene keeps the id `main` that earlier 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.
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ final class Connection {
|
|||||||
let overlays = Overlays()
|
let overlays = Overlays()
|
||||||
var phase: Phase = .starting
|
var phase: Phase = .starting
|
||||||
@ObservationIgnored private var stopped = false
|
@ObservationIgnored private var stopped = false
|
||||||
|
@ObservationIgnored private var restarting = false
|
||||||
|
|
||||||
init(origin: URL) {
|
init(origin: URL) {
|
||||||
self.origin = origin
|
self.origin = origin
|
||||||
@@ -144,13 +145,32 @@ final class Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func start() async {
|
private func start() async {
|
||||||
while !stopped, !(await attempt()) {
|
let old = store
|
||||||
|
while !stopped, !(await attempt(old)) {
|
||||||
try? await Task.sleep(for: .seconds(2))
|
try? await Task.sleep(for: .seconds(2))
|
||||||
}
|
}
|
||||||
|
if let old {
|
||||||
|
await old.stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func attempt() async -> Bool {
|
private func reconnected(_ startedAt: Date) {
|
||||||
let store = Store(api: API(origin: origin))
|
guard !restarting else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
guard let status = try? await API(origin: origin).client.getStatus().ok.body.json, status.startedAt != startedAt, !stopped, !restarting else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.notice("\(self.origin.absoluteString, privacy: .public) restarted, reloading")
|
||||||
|
restarting = true
|
||||||
|
await start()
|
||||||
|
restarting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attempt(_ old: Store?) async -> Bool {
|
||||||
|
let store = Store(api: API(origin: origin), images: old?.images ?? ImageDrafts())
|
||||||
do {
|
do {
|
||||||
let status = try await store.api.client.getStatus().ok.body.json
|
let status = try await store.api.client.getStatus().ok.body.json
|
||||||
async let me: Void = store.loadMe()
|
async let me: Void = store.loadMe()
|
||||||
@@ -159,17 +179,19 @@ final class Connection {
|
|||||||
async let lead: Void = store.loadLeadSessions()
|
async let lead: Void = store.loadLeadSessions()
|
||||||
async let reads: Void = store.loadSummaryReads()
|
async let reads: Void = store.loadSummaryReads()
|
||||||
_ = try await (me, projects, inbox, lead, reads)
|
_ = try await (me, projects, inbox, lead, reads)
|
||||||
store.loadDrafts()
|
|
||||||
try await store.loadFocus()
|
try await store.loadFocus()
|
||||||
guard !stopped else {
|
guard !stopped else {
|
||||||
await store.stop()
|
await store.stop()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
store.loadDrafts(from: old)
|
||||||
Task {
|
Task {
|
||||||
await store.startLeadStream()
|
await store.startLeadStream()
|
||||||
}
|
}
|
||||||
phase = .running(store)
|
phase = .running(store)
|
||||||
store.startGlobalStream(after: status.lastEventSeq)
|
store.startGlobalStream(after: status.lastEventSeq) { [weak self] in
|
||||||
|
self?.reconnected(status.startedAt)
|
||||||
|
}
|
||||||
store.remote.start()
|
store.remote.start()
|
||||||
Notifier.shared.deliver()
|
Notifier.shared.deliver()
|
||||||
return true
|
return true
|
||||||
@@ -394,7 +416,7 @@ struct ServerWindow: View {
|
|||||||
MainView()
|
MainView()
|
||||||
.environment(store)
|
.environment(store)
|
||||||
.environment(connection.overlays)
|
.environment(connection.overlays)
|
||||||
.id(connection.origin)
|
.id(ObjectIdentifier(store))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ConnectView(form: form) { connected in
|
ConnectView(form: form) { connected in
|
||||||
@@ -421,6 +443,7 @@ struct ServerWindow: View {
|
|||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .principal) {
|
||||||
ServerPicker()
|
ServerPicker()
|
||||||
}
|
}
|
||||||
|
.sharedBackgroundVisibility(.hidden)
|
||||||
}
|
}
|
||||||
.toolbar(removing: .title)
|
.toolbar(removing: .title)
|
||||||
.focusedSceneValue(\.serverWindow, focus)
|
.focusedSceneValue(\.serverWindow, focus)
|
||||||
@@ -499,12 +522,18 @@ private struct ServerChip: View {
|
|||||||
Badge(count: questions)
|
Badge(count: questions)
|
||||||
.help("open questions")
|
.help("open questions")
|
||||||
}
|
}
|
||||||
if summaries > 0 {
|
Text("99")
|
||||||
Text("\(summaries) new")
|
.font(Fonts.font(11, weight: 700))
|
||||||
.font(Fonts.font(11, weight: 700))
|
.hidden()
|
||||||
.foregroundStyle(Palette.accent)
|
.overlay(alignment: .leading) {
|
||||||
.help("Turn summaries you haven't read")
|
if summaries > 0 {
|
||||||
}
|
Text("\(summaries)")
|
||||||
|
.font(Fonts.font(11, weight: 700))
|
||||||
|
.foregroundStyle(Palette.accent)
|
||||||
|
.fixedSize()
|
||||||
|
.help("Turn summaries you haven't read")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(hover || current ? Palette.text : questions > 0 ? Palette.warn : Palette.muted)
|
.foregroundStyle(hover || current ? Palette.text : questions > 0 ? Palette.warn : Palette.muted)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ final class Store {
|
|||||||
var todoBusy: [String: String] = [:]
|
var todoBusy: [String: String] = [:]
|
||||||
var pickedMembers: [String: String] = [:]
|
var pickedMembers: [String: String] = [:]
|
||||||
private(set) var drafts: Drafts?
|
private(set) var drafts: Drafts?
|
||||||
let images = ImageDrafts()
|
let images: ImageDrafts
|
||||||
let imageCache = ImageCache()
|
let imageCache = ImageCache()
|
||||||
let events = Listeners<ByreEvent>()
|
let events = Listeners<ByreEvent>()
|
||||||
let paneFocus: PaneFocus
|
let paneFocus: PaneFocus
|
||||||
@@ -74,8 +74,9 @@ final class Store {
|
|||||||
@ObservationIgnored private var stopped = false
|
@ObservationIgnored private var stopped = false
|
||||||
@ObservationIgnored var changesMemory = ChangesMemory()
|
@ObservationIgnored var changesMemory = ChangesMemory()
|
||||||
|
|
||||||
init(api: API) {
|
init(api: API, images: ImageDrafts) {
|
||||||
self.api = api
|
self.api = api
|
||||||
|
self.images = images
|
||||||
leadFeed = Feed(shown: { true })
|
leadFeed = Feed(shown: { true })
|
||||||
paneFocus = PaneFocus(api: api)
|
paneFocus = PaneFocus(api: api)
|
||||||
remote = Remote(api: api)
|
remote = Remote(api: api)
|
||||||
@@ -90,8 +91,8 @@ final class Store {
|
|||||||
await paneFocus.leave()
|
await paneFocus.leave()
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadDrafts() {
|
func loadDrafts(from old: Store?) {
|
||||||
drafts = Drafts(origin: api.origin, login: me!.login)
|
drafts = old?.drafts ?? Drafts(origin: api.origin, login: me!.login)
|
||||||
drafts!.prune(self)
|
drafts!.prune(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,13 +566,14 @@ final class Store {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func startGlobalStream(after: Int64) {
|
func startGlobalStream(after: Int64, reconnected: @escaping () -> Void) {
|
||||||
globalStream = EventStream(api: api, after: after, lead: false, onEvent: { [weak self] ev in
|
globalStream = EventStream(api: api, after: after, lead: false, onEvent: { [weak self] ev in
|
||||||
self?.handleEvent(ev)
|
self?.handleEvent(ev)
|
||||||
}, onState: { [weak self] up in
|
}, onState: { [weak self] up in
|
||||||
self?.connected = up
|
self?.connected = up
|
||||||
if up {
|
if up {
|
||||||
self?.paneFocus.reconnected()
|
self?.paneFocus.reconnected()
|
||||||
|
reconnected()
|
||||||
}
|
}
|
||||||
}, onReady: {})
|
}, onReady: {})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user