Wait for loaded recents and move account actions into Mac menus
14 files changed,
+176
−8
native/apple/Sources/SheetsApp/SheetsAccountWindow.swift+4 −1native/apple/Sources/SheetsApp/SheetsWindowCommands.swift+11 −0native/apple/Sources/SheetsApp/SheetsWindows.swift+1 −0native/apple/Sources/SheetsKit/SheetModel.swift+11 −1native/apple/Sources/SheetsKit/SheetsAccountActions.swift+10 −0native/apple/Sources/SheetsKit/SheetsWorkspace.swift+11 −6native/apple/Tests/SheetsKitTests/HomeLoadingTests.swift+22 −0native/scripts/test-home-loading.py+36 −0native/smoke/account-menu/.gitignore+2 −0native/smoke/account-menu/Package.resolved+15 −0native/smoke/account-menu/Package.swift+7 −0native/smoke/account-menu/Sources/SheetsWindowCommands.swift+1 −0native/smoke/account-menu/Sources/SheetsWindows.swift+1 −0native/smoke/account-menu/Sources/Smoke.swift+44 −0
CI passed — run details
diff --git a/native/apple/Sources/SheetsApp/SheetsAccountWindow.swift b/native/apple/Sources/SheetsApp/SheetsAccountWindow.swift| index 4bbf1c9..295b790 100644 |
| --- a/native/apple/Sources/SheetsApp/SheetsAccountWindow.swift |
| +++ b/native/apple/Sources/SheetsApp/SheetsAccountWindow.swift |
| @@ -19,7 +19,7 @@ struct SheetsAccountWindow: View { |
| guard connection.activeAccountID == account, windows.canSignOut(account) else { return } |
| windows.entries.removeAll { $0.account == account } |
| connection.signOut() |
| - }, canSignOut: windows.canSignOut(account), createRequested: $windows.createRequested, openDocument: { document, title in |
| + }, canSignOut: windows.canSignOut(account), createRequested: $windows.createRequested, accountsRequested: $windows.accountsRequested, openDocument: { document, title in |
| guard connection.activeAccountID == account else { return } |
| let id = windows.reserve(account: account, document: document, title: title, client: client) |
| openWindow(id: "document", value: id) |
| @@ -38,6 +38,9 @@ struct SheetsAccountWindow: View { |
| } |
| }.onOpenURL(perform: connection.handleOpenURL) |
| #if os(macOS) |
| + .onChange(of: windows.accountsRequested, initial: true) { _, requested in |
| + if requested && connection.client == nil { windows.accountsRequested = false } |
| + } |
| .focusedSceneValue(\.sheetsWindowAccount, connection.activeAccountID) |
| .onChange(of: connection.activeAccountID, initial: true) { _, value in windows.activeAccount = value } |
| #endif |
diff --git a/native/apple/Sources/SheetsApp/SheetsWindowCommands.swift b/native/apple/Sources/SheetsApp/SheetsWindowCommands.swift| index 77b325f..28f2105 100644 |
| --- a/native/apple/Sources/SheetsApp/SheetsWindowCommands.swift |
| +++ b/native/apple/Sources/SheetsApp/SheetsWindowCommands.swift |
| @@ -5,8 +5,19 @@ import SheetsKit |
| struct SheetsWindowCommands: Commands { |
| let windows: SheetsWindows |
| @FocusedValue(\.sheetsWindowAccount) private var account |
| + @FocusedValue(\.sheetsAccountActions) private var accountActions |
| @Environment(\.openWindow) private var openWindow |
| var body: some Commands { |
| + CommandGroup(after: .appSettings) { |
| + Button(sheetsMacString("Accounts")) { |
| + if let action = accountActions?.accounts { action() } |
| + else { windows.accountsRequested = true; openWindow(id: "home") } |
| + }.disabled(accountActions?.canManage == false) |
| + Button(sheetsMacString("Sign out")) { accountActions?.signOut() } |
| + .disabled(accountActions?.canSignOut != true) |
| + Divider() |
| + } |
| + |
| CommandGroup(replacing: .newItem) { |
| Button(sheetsMacString("New spreadsheet")) { |
| windows.createRequested = true |
diff --git a/native/apple/Sources/SheetsApp/SheetsWindows.swift b/native/apple/Sources/SheetsApp/SheetsWindows.swift| index d8e502b..90a953d 100644 |
| --- a/native/apple/Sources/SheetsApp/SheetsWindows.swift |
| +++ b/native/apple/Sources/SheetsApp/SheetsWindows.swift |
| @@ -17,6 +17,7 @@ import SwiftUI |
| var entries: [Entry] = [] |
| var activeAccount: String? |
| var createRequested = false |
| + var accountsRequested = false |
| func reserve(account: String, document: UInt64, title: String, client: NativeHTTP) -> UUID { |
| if let index = entries.firstIndex(where: { $0.account == account && $0.client.origin == client.origin && $0.document == document }) { |
| var entry = entries.remove(at: index) |
diff --git a/native/apple/Sources/SheetsKit/SheetModel.swift b/native/apple/Sources/SheetsKit/SheetModel.swift| index 560ac95..81f2469 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetModel.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetModel.swift |
| @@ -3,6 +3,9 @@ import Observation |
| import RastrilloApple |
| @MainActor @Observable final class SheetModel { |
| + enum ListState { case loading, loaded, failed } |
| + var listState = ListState.loading |
| + private var listRequest = UUID() |
| var sheets: [SheetSummary] = [] |
| var page: SheetPage? |
| var cells: [Coordinate: Cell] = [:] |
| @@ -38,10 +41,17 @@ import RastrilloApple |
| var cols: Int { page?.Cols ?? 26 } |
| var mayWrite: Bool { page?.MayWrite == true } |
| func loadList() async { |
| + let request = UUID(); listRequest = request |
| + listState = .loading |
| do { |
| let response: SheetList = try await client.request("/sheets") |
| + guard listRequest == request, !Task.isCancelled else { return } |
| sheets = response.Sheets ?? [] |
| - } catch { self.error = error.localizedDescription } |
| + listState = .loaded; error = "" |
| + } catch { |
| + guard listRequest == request else { return } |
| + listState = .failed; self.error = error.localizedDescription |
| + } |
| } |
| func open(_ sheet: SheetSummary) async { |
| guard !hasUnconfirmedChanges && !busy else { |
diff --git a/native/apple/Sources/SheetsKit/SheetsAccountActions.swift b/native/apple/Sources/SheetsKit/SheetsAccountActions.swift| new file mode 100644 |
| index 0000000..4ee6c4b |
| --- /dev/null |
| +++ b/native/apple/Sources/SheetsKit/SheetsAccountActions.swift |
| @@ -0,0 +1,10 @@ |
| +#if os(macOS) |
| +import SwiftUI |
| +public struct SheetsAccountActions { |
| + public let accounts: (() -> Void)? |
| + public let signOut: () -> Void |
| + public let canManage: Bool |
| + public let canSignOut: Bool |
| +} |
| +extension FocusedValues { @Entry public var sheetsAccountActions: SheetsAccountActions? } |
| +#endif |
diff --git a/native/apple/Sources/SheetsKit/SheetsWorkspace.swift b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift| index 77cffab..72dec82 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetsWorkspace.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift |
| @@ -17,13 +17,14 @@ import SwiftUI |
| @AppStorage("sheets.appearance") private var appearance = "auto" |
| private let updateRecents: (([(UInt64, String)]) -> Void)? |
| private let canSignOut: Bool |
| + @Binding private var accountsRequested: Bool |
| @Binding private var createRequested: Bool |
| private let documentID: UInt64? |
| private let openDocument: ((UInt64, String) -> Void)? |
| private let signOut: () -> Void |
| private let accounts: (() -> Void)? |
| - public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void, documentID: UInt64? = nil, canSignOut: Bool = true, createRequested: Binding<Bool> = .constant(false), openDocument: ((UInt64, String) -> Void)? = nil, updateRecents: (([(UInt64, String)]) -> Void)? = nil) { |
| - self.updateRecents = updateRecents; self.canSignOut = canSignOut; self._createRequested = createRequested; self.documentID = documentID; self.openDocument = openDocument |
| + public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void, documentID: UInt64? = nil, canSignOut: Bool = true, createRequested: Binding<Bool> = .constant(false), accountsRequested: Binding<Bool> = .constant(false), openDocument: ((UInt64, String) -> Void)? = nil, updateRecents: (([(UInt64, String)]) -> Void)? = nil) { |
| + self.updateRecents = updateRecents; self.canSignOut = canSignOut; self._createRequested = createRequested; self._accountsRequested = accountsRequested; self.documentID = documentID; self.openDocument = openDocument |
| _model = State(initialValue: SheetModel(client: client)) |
| self.signOut = signOut |
| self.accounts = accounts |
| @@ -49,6 +50,9 @@ import SwiftUI |
| .onChange(of: model.sheets.map { "\($0.ID)\u{0}\($0.Title)" }) { |
| if query.isEmpty { updateRecents?(model.sheets.map { ($0.ID, $0.Title) }) } |
| } |
| + .onChange(of: accountsRequested && !model.busy, initial: true) { _, ready in |
| + if ready { accountsRequested = false; accounts?() } |
| + } |
| .onChange(of: createRequested, initial: true) { _, requested in |
| if requested && documentID == nil { createRequested = false; Task { await createSheet() } } |
| } |
| @@ -121,7 +125,8 @@ import SwiftUI |
| } |
| .listStyle(.plain) |
| .overlay { |
| - if model.sheets.isEmpty && model.error.isEmpty { |
| + if model.listState == .loading { ProgressView() } |
| + if model.listState == .loaded && model.sheets.isEmpty { |
| ContentUnavailableView("No spreadsheets", systemImage: "tablecells", |
| description: Text("Create a spreadsheet or import a workbook.")) |
| } else if filteredSheets.isEmpty && !query.isEmpty { |
| @@ -283,19 +288,19 @@ import SwiftUI |
| }.buttonStyle(.plain) |
| } |
| } |
| - if filteredSheets.isEmpty { |
| + if model.listState == .loading { ProgressView().frame(maxWidth: .infinity) } |
| + if model.listState == .loaded && model.sheets.isEmpty { |
| ContentUnavailableView("No spreadsheets", systemImage: "tablecells", description: Text("Create a spreadsheet or import a workbook.")) |
| } |
| }.padding(28) |
| } |
| .navigationTitle(sheetsMacString("Home")) |
| .searchable(text: $query, prompt: sheetsMacString("Search spreadsheets")) |
| + .focusedSceneValue(\.sheetsAccountActions, SheetsAccountActions(accounts: accounts, signOut: signOut, canManage: !model.busy, canSignOut: canSignOut && !model.busy)) |
| .toolbar { |
| - if let accounts { Button(sheetsMacString("Accounts"), action: accounts).disabled(model.busy) } |
| newSheet |
| Button(sheetsMacString("Import workbook")) { show("Import") } |
| Button(sheetsMacString("Refresh"), systemImage: "arrow.clockwise") { Task { await model.loadList() } } |
| - Button(sheetsMacString("Sign out"), action: signOut).disabled(!canSignOut || model.busy).help(sheetsMacString("Close this account’s document windows before signing out.")) |
| } |
| } |
| } |
diff --git a/native/apple/Tests/SheetsKitTests/HomeLoadingTests.swift b/native/apple/Tests/SheetsKitTests/HomeLoadingTests.swift| new file mode 100644 |
| index 0000000..4bc56a9 |
| --- /dev/null |
| +++ b/native/apple/Tests/SheetsKitTests/HomeLoadingTests.swift |
| @@ -0,0 +1,22 @@ |
| +import Foundation |
| +import RastrilloApple |
| +import XCTest |
| +@testable import SheetsKit |
| + |
| +final class HomeLoadingTests: XCTestCase { |
| + @MainActor func testLoadingSuccessAndFailureAreDistinct() async throws { |
| + guard let origin = ProcessInfo.processInfo.environment["HOME_LOADING_TEST_ORIGIN"] else { |
| + throw XCTSkip("Run the delayed home-list fixture") |
| + } |
| + let model = SheetModel(client: NativeHTTP(origin: URL(string: origin)!)) |
| + XCTAssertEqual(model.listState, .loading) |
| + for expected in [1, 0, -1, 1] { |
| + let request = Task { await model.loadList() } |
| + try await Task.sleep(for: .milliseconds(25)) |
| + XCTAssertEqual(model.listState, .loading, "Never classify an in-flight request as empty") |
| + await request.value |
| + XCTAssertEqual(model.listState, expected == -1 ? .failed : .loaded) |
| + if expected >= 0 { XCTAssertEqual(model.sheets.count, expected) } |
| + } |
| + } |
| +} |
diff --git a/native/scripts/test-home-loading.py b/native/scripts/test-home-loading.py| new file mode 100644 |
| index 0000000..2972dfd |
| --- /dev/null |
| +++ b/native/scripts/test-home-loading.py |
| @@ -0,0 +1,36 @@ |
| +#!/usr/bin/env python3 |
| +"""Verify loading/populated/empty/failure/retry against delayed HTTP responses.""" |
| +import http.server |
| +import json |
| +import os |
| +from pathlib import Path |
| +import subprocess |
| +import threading |
| +import time |
| + |
| +APP = "Sheets" |
| +root = Path(__file__).resolve().parents[2] |
| +class Handler(http.server.BaseHTTPRequestHandler): |
| + count = 0 |
| + def do_GET(self): |
| + type(self).count += 1 |
| + number = type(self).count |
| + time.sleep(0.2) |
| + item = ({"sqid": "fixture", "title": "Fixture"} if APP == "Docs" else |
| + {"ID": 1, "Title": "Fixture", "TabID": 1, "TabName": "Sheet1"}) |
| + items = [] if number == 2 else [item] |
| + payload = {"documents": items, "can_create": True} if APP == "Docs" else {"Sheets": items} |
| + data = json.dumps(payload).encode() |
| + self.send_response(503 if number == 3 else 200) |
| + self.send_header("Content-Length", str(len(data))) |
| + self.end_headers() |
| + self.wfile.write(data) |
| + def log_message(self, *_): |
| + pass |
| +with http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server: |
| + threading.Thread(target=server.serve_forever, daemon=True).start() |
| + result = subprocess.run(["swift", "test", "--package-path", str(root / "native/apple"), |
| + "--filter", "HomeLoadingTests"], |
| + env=dict(os.environ, HOME_LOADING_TEST_ORIGIN=f"http://127.0.0.1:{server.server_port}"), timeout=180) |
| + server.shutdown() |
| + raise SystemExit(result.returncode) |
diff --git a/native/smoke/account-menu/.gitignore b/native/smoke/account-menu/.gitignore| new file mode 100644 |
| index 0000000..2d9f16e |
| --- /dev/null |
| +++ b/native/smoke/account-menu/.gitignore |
| @@ -0,0 +1,2 @@ |
| +.build/ |
| +.swiftpm/ |
diff --git a/native/smoke/account-menu/Package.resolved b/native/smoke/account-menu/Package.resolved| new file mode 100644 |
| index 0000000..fc45d25 |
| --- /dev/null |
| +++ b/native/smoke/account-menu/Package.resolved |
| @@ -0,0 +1,15 @@ |
| +{ |
| + "originHash" : "4b95d7ba10a7141177d7e08a60664b6584675643fcb52b16880a425c24e413b5", |
| + "pins" : [ |
| + { |
| + "identity" : "sparkle", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/sparkle-project/Sparkle", |
| + "state" : { |
| + "revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a", |
| + "version" : "2.9.6" |
| + } |
| + } |
| + ], |
| + "version" : 3 |
| +} |
diff --git a/native/smoke/account-menu/Package.swift b/native/smoke/account-menu/Package.swift| new file mode 100644 |
| index 0000000..e85f7ac |
| --- /dev/null |
| +++ b/native/smoke/account-menu/Package.swift |
| @@ -0,0 +1,7 @@ |
| +// swift-tools-version: 6.2 |
| +import PackageDescription |
| +let package = Package(name: "AccountMenuSmoke", platforms: [.macOS(.v26)], |
| + dependencies: [.package(path: "../../apple")], |
| + targets: [.executableTarget(name: "AccountMenuSmoke", dependencies: [ |
| + .product(name: "SheetsKit", package: "apple") |
| + ], path: "Sources")]) |
diff --git a/native/smoke/account-menu/Sources/SheetsWindowCommands.swift b/native/smoke/account-menu/Sources/SheetsWindowCommands.swift| new file mode 120000 |
| index 0000000..52902aa |
| --- /dev/null |
| +++ b/native/smoke/account-menu/Sources/SheetsWindowCommands.swift |
| @@ -0,0 +1 @@ |
| +../../../apple/Sources/SheetsApp/SheetsWindowCommands.swift |
| \ No newline at end of file |
diff --git a/native/smoke/account-menu/Sources/SheetsWindows.swift b/native/smoke/account-menu/Sources/SheetsWindows.swift| new file mode 120000 |
| index 0000000..2ce7d4f |
| --- /dev/null |
| +++ b/native/smoke/account-menu/Sources/SheetsWindows.swift |
| @@ -0,0 +1 @@ |
| +../../../apple/Sources/SheetsApp/SheetsWindows.swift |
| \ No newline at end of file |
diff --git a/native/smoke/account-menu/Sources/Smoke.swift b/native/smoke/account-menu/Sources/Smoke.swift| new file mode 100644 |
| index 0000000..9173131 |
| --- /dev/null |
| +++ b/native/smoke/account-menu/Sources/Smoke.swift |
| @@ -0,0 +1,44 @@ |
| +import AppKit |
| +import SwiftUI |
| +import SheetsKit |
| + |
| +@MainActor private enum Run { static var started = false } |
| +@main struct AccountMenuSmoke: App { |
| + @State private var windows = SheetsWindows() |
| + var body: some Scene { |
| + Window("Smoke Home", id: "home") { Launcher(windows: windows) } |
| + .commands { SheetsWindowCommands(windows: windows) } |
| + Window("Smoke Document", id: "document") { Text("Document").frame(width: 500, height: 300) } |
| + } |
| +} |
| +private struct Launcher: View { |
| + let windows: SheetsWindows |
| + @Environment(\.openWindow) private var openWindow |
| + var body: some View { |
| + Text("Home").frame(width: 400, height: 200).task { |
| + guard !Run.started else { return } |
| + Run.started = true |
| + NSApp.setActivationPolicy(.regular) |
| + openWindow(id: "document") |
| + try? await Task.sleep(for: .milliseconds(500)) |
| + NSApp.windows.first(where: { $0.title == "Smoke Home" })?.close() |
| + let document = NSApp.windows.first(where: { $0.title == "Smoke Document" })! |
| + NSApp.activate(ignoringOtherApps: true) |
| + document.makeKeyAndOrderFront(nil) |
| + try? await Task.sleep(for: .milliseconds(500)) |
| + let menu = NSApp.mainMenu!.items.first!.submenu! |
| + menu.delegate?.menuNeedsUpdate?(menu) |
| + menu.update() |
| + guard let accounts = menu.items.first(where: { $0.title == sheetsMacString("Accounts") }), accounts.isEnabled, |
| + let signOut = menu.items.first(where: { $0.title == sheetsMacString("Sign out") }), !signOut.isEnabled else { |
| + fatalError("Account commands missing or unsafe") |
| + } |
| + menu.performActionForItem(at: menu.index(of: accounts)) |
| + try? await Task.sleep(for: .milliseconds(300)) |
| + precondition(windows.accountsRequested) |
| + precondition(NSApp.windows.contains { $0.title == "Smoke Home" && $0.isVisible }) |
| + print("PASS: Accounts and Sign out live in app menu; Accounts reopens closed Home") |
| + NSApp.terminate(nil) |
| + } |
| + } |
| +} |