oficina / sheets Public

Wait for loaded recents and move account actions into Mac menus

Paul Campbell pushed by paul@keymail.dev ca6f0e05021fde9d01a77e1c20cc9b92dcdcf363 parent 736d34f
14 files changed, +176 −8
  • native/apple/Sources/SheetsApp/SheetsAccountWindow.swift +4 −1
  • native/apple/Sources/SheetsApp/SheetsWindowCommands.swift +11 −0
  • native/apple/Sources/SheetsApp/SheetsWindows.swift +1 −0
  • native/apple/Sources/SheetsKit/SheetModel.swift +11 −1
  • native/apple/Sources/SheetsKit/SheetsAccountActions.swift +10 −0
  • native/apple/Sources/SheetsKit/SheetsWorkspace.swift +11 −6
  • native/apple/Tests/SheetsKitTests/HomeLoadingTests.swift +22 −0
  • native/scripts/test-home-loading.py +36 −0
  • native/smoke/account-menu/.gitignore +2 −0
  • native/smoke/account-menu/Package.resolved +15 −0
  • native/smoke/account-menu/Package.swift +7 −0
  • native/smoke/account-menu/Sources/SheetsWindowCommands.swift +1 −0
  • native/smoke/account-menu/Sources/SheetsWindows.swift +1 −0
  • native/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)
+ }
+ }
+}