oficina / sheets Public

Merge native/document-windows into main

Paul Campbell pushed by paul@keymail.dev 30e22d2bee3e417f6160fb3524d360f37727c309 parent 336aa0dparent 39a67f3
68 files changed, +3117 −179
  • docs/superpowers/reviews/2026-09-22-native-input-resize.md +11 −0
  • docs/superpowers/specs/2026-09-20-mac-document-windows.md +85 −0
  • native/PrivacyInfo.xcprivacy +17 −0
  • native/apple/Package.resolved +11 −2
  • native/apple/Package.swift +11 −5
  • native/apple/Sources/SheetsApp/SheetsAccountWindow.swift +52 −0
  • native/apple/Sources/SheetsApp/SheetsApp.swift +23 −12
  • native/apple/Sources/SheetsApp/SheetsDocumentWindow.swift +23 −0
  • native/apple/Sources/SheetsApp/SheetsWindowCommands.swift +43 −0
  • native/apple/Sources/SheetsApp/SheetsWindows.swift +48 −0
  • native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings +72 −0
  • native/apple/Sources/SheetsKit/SheetAutoSize.swift +18 −0
  • native/apple/Sources/SheetsKit/SheetCell.swift +38 −7
  • native/apple/Sources/SheetsKit/SheetCellEditor.swift +73 −0
  • native/apple/Sources/SheetsKit/SheetEditingControls.swift +48 −0
  • native/apple/Sources/SheetsKit/SheetEditorContext.swift +9 −0
  • native/apple/Sources/SheetsKit/SheetFiles.swift +3 −1
  • native/apple/Sources/SheetsKit/SheetGrid.swift +173 −13
  • native/apple/Sources/SheetsKit/SheetLive.swift +20 −10
  • native/apple/Sources/SheetsKit/SheetModel.swift +40 −14
  • native/apple/Sources/SheetsKit/SheetPanels.swift +2 −1
  • native/apple/Sources/SheetsKit/SheetResizeHandle.swift +65 −0
  • native/apple/Sources/SheetsKit/SheetTable.swift +1 −1
  • native/apple/Sources/SheetsKit/SheetThumbnail.swift +49 −0
  • native/apple/Sources/SheetsKit/SheetWire.swift +1 −0
  • native/apple/Sources/SheetsKit/SheetWireWorker.swift +19 −0
  • native/apple/Sources/SheetsKit/SheetWrites.swift +4 −8
  • native/apple/Sources/SheetsKit/SheetsAccountActions.swift +10 −0
  • native/apple/Sources/SheetsKit/SheetsDocumentMenu.swift +24 −0
  • native/apple/Sources/SheetsKit/SheetsEditorCommands.swift +61 −0
  • native/apple/Sources/SheetsKit/SheetsMacStrings.swift +15 −0
  • native/apple/Sources/SheetsKit/SheetsWorkspace.swift +176 −74
  • native/apple/Tests/SheetsKitTests/HomeLoadingTests.swift +22 −0
  • native/apple/Tests/SheetsKitTests/ResizeFixtureServer.swift +58 −0
  • native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift +159 −0
  • native/apple/Tests/SheetsKitTests/SheetWireWorkerTests.swift +39 −0
  • native/apple/Tests/SheetsWindowTests/WindowRoutingTests.swift +27 −0
  • native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png binary
  • native/ios/Info.plist +35 −0
  • native/ios/UITests/NativeUITests.swift +3 −3
  • native/ios/project.yml +45 −27
  • native/mac-updates.json +6 −0
  • native/macos/AppIcon.icns binary
  • native/macos/AppStore.entitlements +12 −0
  • native/macos/Info.plist +43 −0
  • native/macos/SheetsMacStore.xcodeproj/project.pbxproj +391 −0
  • native/macos/SheetsMacStore.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 −0
  • native/macos/project.yml +54 −0
  • native/scripts/build-mac.sh +2 −1
  • native/scripts/test-home-loading.py +36 −0
  • native/scripts/test-mac-bundle.py +71 −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

CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s)
=== vet ===
CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
=== migrations ===
rastrillo migration check
rastrillo migration check: models and migrations agree
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
?   	amadan.net/oficina/sheets/cmd/sheets	[no test files]
ok  	amadan.net/oficina/sheets/internal/guardstest	0.077s
ok  	amadan.net/oficina/sheets/internal/sheets	10.280s
ok  	amadan.net/oficina/sheets/internal/sheets/calc	0.010s
ok  	amadan.net/oficina/sheets/internal/sheets/format	0.005s
ok  	amadan.net/oficina/sheets/internal/sheets/live	0.055s
ok  	amadan.net/oficina/sheets/internal/sheets/seal	0.005s
ok  	amadan.net/oficina/sheets/internal/sheets/sealtest	0.005s
ok  	amadan.net/oficina/sheets/internal/sheets/store	2.713s
ok  	amadan.net/oficina/sheets/internal/sheets/xlsx	0.283s
?   	amadan.net/oficina/sheets/tools/gencorpus	[no test files]
=== race ===
go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/...
ok  	amadan.net/oficina/sheets/internal/sheets/store	45.678s
ok  	amadan.net/oficina/sheets/internal/sheets/live	1.059s
diff --git a/docs/superpowers/reviews/2026-09-22-native-input-resize.md b/docs/superpowers/reviews/2026-09-22-native-input-resize.md
new file mode 100644
index 0000000..5218156
--- /dev/null
+++ b/docs/superpowers/reviews/2026-09-22-native-input-resize.md
@@ -0,0 +1,11 @@
+# Native Sheets input and resizing
+
+Reviewed and approved by GPT-6 Astra (highest-capability model available in this session).
+
+Typing two keys 0–5 ms apart reproduced dropped/overwritten characters during the SwiftUI field focus handoff. The native field positions its caret while gaining focus, and the grid buffers printable events arriving before that handoff completes.
+
+Selection observation now lives in a leaf rather than invalidating the grid hierarchy. Sync responses without changed cells/styles no longer reassign those maps. Resize response decoding uses the existing background actor. These remove identified unnecessary UI work; no end-to-end large-workbook latency claim is made.
+
+Native row/column handles preview sizes locally, clamp to server bounds, and submit one asynchronous save on release. Escape or removal cancels unsaved previews; failures restore server-confirmed sizes. Existing menu sizing and column double-click autofit remain available. Handles accept the first mouse event in an inactive window.
+
+Validation: full Swift test run passed (4 native interaction tests, 12 Swift Testing tests; authenticated live test skipped without credentials). Native tests cover typing at 0/1/5/10/25/50 ms intervals, failed-commit draft preservation, selection, both resize axes, preview geometry, no requests during drag/cancellation, exactly one request after release, confirmed sizes, and failure rollback. Mouse drags route through NSWindow; Escape is sent to the handle directly because XCTest's window remains inactive. iOS simulator build passed. Final rerun after conditional dictionary changes passed; git diff --check passed.
diff --git a/docs/superpowers/specs/2026-09-20-mac-document-windows.md b/docs/superpowers/specs/2026-09-20-mac-document-windows.md
new file mode 100644
index 0000000..abefde8
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-20-mac-document-windows.md
@@ -0,0 +1,85 @@
+# Mac document windows — 2026-09-20
+
+Paul requests a separate Home launcher and document windows for Docs and Sheets.
+This extends the native Apple specs; editors remain native and server-backed.
+
+Home is a singleton with a Recent sidebar and searchable thumbnail grid. Templates
+are deferred until template creation exists. Each open document has an independent
+model, live stream, selection and save queue. Window identity includes the account
+and document; opening an existing document focuses it. Document/session state remains in memory; credentials retain the existing
+Keychain handling and are never encoded in scene restoration values. Missing restored sessions return
+the user to Home. File > Recent and Window > Home remain available with no editor.
+Recent entries are held in memory for this iteration, scoped to the account session.
+
+Mac document windows have no app navigation sidebar. Existing editing and file
+actions move into menu-bar commands targeted at the focused scene. Standard native
+text responder actions remain available in text fields. Docs adds a document-only
+inspector and zoom starting at 100%; zoom changes display scale, never text wrapping.
+No server page-settings API exists: inspector exposes existing personal layout
+settings and document information, without pretending to persist new paper settings.
+
+Sheets edits in the selected cell, preserving drafts on failed save and respecting
+existing write serialization. Header buttons select whole rows/columns. Double-click
+on column boundaries autosizes using displayed text/font measurements and existing
+size endpoint; menu alternative provided. Remove only the grid's outer focus effect,
+retain cell selection and keyboard focus. Enter/Tab commit; Escape discards.
+
+Docs rendering investigation compares web CSS/font and page geometry against TextKit.
+Use identical bundled fonts, logical width, sizes, paragraph spacing and line height.
+Native optimistic edits must not be overwritten by stale projection generations or
+IME composition. Avoid unnecessary complete attributed storage replacement and
+selection resets; preserve model render acknowledgement. Exact cross-engine layout
+parity requires measured fixture verification, not a compilation claim.
+
+Validate Mac builds, existing native tests and focused regression cases; exercise
+window/menu targeting and editing where UI automation is available. Build iOS to
+check shared code. No merge, deployment or app publication is part of this change.
+
+Review: GPT-6 Astra (high), 2026-09-20. Required invariants incorporated:
+window keys combine canonical origin, account identity and document identity;
+clients are immutable per window. Sign out is unavailable until that account's
+windows close; switching the launcher account leaves those windows intact.
+Close/quit guards cover in-flight, queued, failed and composing edits. Window
+registry lifecycle ends only after the actual guarded close. Edit commands obey
+the first responder, not merely the focused scene. Render reconciliation compares
+actual optimistic text storage, updates CRDT metadata and acknowledges visually
+equal generations. Zoom keeps logical layout independent of magnified viewport.
+
+## Verification and remaining coverage
+
+Implementation reviewed by GPT-6 Astra (high), including a final account-isolation
+and localization review. Both native Mac test suites and iOS simulator builds pass.
+A synthetic AppKit interaction fixture exercises Sheets typing successive characters,
+failed-save draft retention, Escape, and whole-row/column selection. A live SwiftUI
+scene probe verified File actions and Window > Home after closing Home.
+
+Docs regression tests cover minimal text-storage reconciliation, metadata-only
+updates and fixed logical page width. Deliberately restoring full storage replacement
+and viewport-dependent width made the corresponding tests fail. A synthetic WebKit
+fixture matches native heading, bold and body line breaks and body line spacing.
+
+The follow-up comparison suite loads the production web CSS, bundled fonts and
+pages.mjs pagination functions directly into WebKit. It covers nested lists and
+quotes, heading margins, serif/monospace fonts, inline code including a code span
+starting on a wrapped line, mixed 72pt text, whole-block pagination and an oversized
+first paragraph. Native Mac pagination now uses the same 648pt top-level-block
+budget, an 18pt separator and collapsed surrounding margins. It does not insert
+characters or change CRDT data. Font struts and per-run half-leading determine
+line height; nested margins are collapsed before TextKit layout.
+
+Storage edits invalidate separator offsets immediately, including optimistic edits
+and marked-text composition. A regression test inserts, composes and deletes before
+an existing separator. Unchanged view layouts reuse the measured pagination plan.
+Deliberately removing code-start padding and storage invalidation made their tests
+fail for the intended reasons; the restored implementations pass.
+
+Follow-up review: GPT-6 Astra (high), 2026-09-20; no remaining blocker in the bounded
+source review. Docs validation: 39 XCTest cases plus 15 Swift Testing cases pass,
+as does the iOS simulator build. The web comparison allows one CSS pixel for
+per-line metric rounding and 1.5pt accumulated position error across a long fixture.
+These are measured layout results, not a claim of pixel-identical rasterization or
+proof for every possible document. iOS retains its existing responsive paginator;
+the document-window pagination change targets Mac as requested.
+
+Builds are local ad-hoc Mac applications, not signed distribution or TestFlight
+releases. No server deployment, merge or publication was performed.
diff --git a/native/PrivacyInfo.xcprivacy b/native/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..79bc9e2
--- /dev/null
+++ b/native/PrivacyInfo.xcprivacy
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>NSPrivacyAccessedAPITypes</key>
+ <array>
+ <dict>
+ <key>NSPrivacyAccessedAPIType</key>
+ <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
+ <key>NSPrivacyAccessedAPITypeReasons</key>
+ <array>
+ <string>CA92.1</string>
+ </array>
+ </dict>
+ </array>
+</dict>
+</plist>
diff --git a/native/apple/Package.resolved b/native/apple/Package.resolved
index 64d5af2..409646d 100644
--- a/native/apple/Package.resolved
+++ b/native/apple/Package.resolved
@@ -1,12 +1,21 @@
{
- "originHash" : "95848ac3db32ddb942bdc47a84b774889b32bdbc0e52aa27bbbc232f34802013",
+ "originHash" : "13a00072049b15231376e022de38fd86d188294f4d9d77e737067588243a631c",
"pins" : [
{
"identity" : "native",
"kind" : "remoteSourceControl",
"location" : "https://amadan.net/rastrillo/native",
"state" : {
- "revision" : "a49307c29cf36dff2cf423c3f98b38269ce9a553"
+ "revision" : "229525909e1d6ac3b6a353d1c4d022798b0e9a24"
+ }
+ },
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a",
+ "version" : "2.9.6"
}
}
],
diff --git a/native/apple/Package.swift b/native/apple/Package.swift
index 4dc734e..1b1d410 100644
--- a/native/apple/Package.swift
+++ b/native/apple/Package.swift
@@ -1,9 +1,15 @@
// swift-tools-version: 6.2
import PackageDescription
-let package = Package(name: "SheetsNative", platforms: [.macOS(.v26), .iOS(.v26)],
+import Foundation
+
+let sharedNative: Package.Dependency = ProcessInfo.processInfo.environment["OFICINA_NATIVE_PACKAGE_PATH"].map { .package(name: "native", path: $0) }
+ ?? .package(url: "https://amadan.net/rastrillo/native", revision: "229525909e1d6ac3b6a353d1c4d022798b0e9a24")
+
+let package = Package(name: "SheetsNative", defaultLocalization: "en", platforms: [.macOS(.v26), .iOS(.v26)],
products: [.library(name:"SheetsKit",targets:["SheetsKit"]), .executable(name:"SheetsMac",targets:["SheetsMac"]), .executable(name:"SheetsReview",targets:["SheetsReview"])],
- dependencies:[.package(url:"https://amadan.net/rastrillo/native",revision:"a49307c29cf36dff2cf423c3f98b38269ce9a553")],
- targets:[.target(name:"SheetsKit",dependencies:[.product(name:"RastrilloApple",package:"native")]),
- .executableTarget(name:"SheetsMac",dependencies:["SheetsKit",.product(name:"RastrilloApple",package:"native")],path:"Sources/SheetsApp"),
+ dependencies:[sharedNative],
+ targets:[.target(name:"SheetsKit",dependencies:[.product(name:"RastrilloApple",package:"native")], resources: [.process("Resources")]),
+ .executableTarget(name:"SheetsMac",dependencies:[.product(name: "RastrilloMacUpdates", package: "native", condition: .when(platforms: [.macOS])), "SheetsKit",.product(name:"RastrilloApple",package:"native")],path:"Sources/SheetsApp"),
.executableTarget(name:"SheetsReview",dependencies:["SheetsKit",.product(name:"RastrilloApple",package:"native")]),
- .testTarget(name:"SheetsKitTests",dependencies:["SheetsKit"])])
+ .testTarget(name:"SheetsKitTests",dependencies:["SheetsKit"]),
+ .testTarget(name:"SheetsWindowTests",dependencies:["SheetsMac",.product(name:"RastrilloApple",package:"native")])])
diff --git a/native/apple/Sources/SheetsApp/SheetsAccountWindow.swift b/native/apple/Sources/SheetsApp/SheetsAccountWindow.swift
new file mode 100644
index 0000000..295b790
--- /dev/null
+++ b/native/apple/Sources/SheetsApp/SheetsAccountWindow.swift
@@ -0,0 +1,52 @@
+import RastrilloApple
+import SheetsKit
+import SwiftUI
+
+/// Each window owns its navigation and model lifetime. Switching an account in
+/// one window must never discard another window's unconfirmed edits.
+struct SheetsAccountWindow: View {
+ @StateObject private var connection = NativeConnection(defaultServer: "https://sheets.oficina.works")
+ #if os(macOS)
+ @Bindable var windows: SheetsWindows
+ @Environment(\.openWindow) private var openWindow
+ #endif
+ var body: some View {
+ Group {
+ if let client = connection.client {
+ #if os(macOS)
+ if let account = connection.activeAccountID {
+ SheetsWorkspace(client: client, accounts: connection.showAccounts, signOut: {
+ guard connection.activeAccountID == account, windows.canSignOut(account) else { return }
+ windows.entries.removeAll { $0.account == account }
+ connection.signOut()
+ }, 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)
+ }, updateRecents: { documents in
+ guard connection.activeAccountID == account else { return }
+ windows.updateRecents(documents, account: account, client: client)
+ })
+ .id(account)
+ }
+ #else
+ SheetsWorkspace(client: client, accounts: connection.showAccounts, signOut: connection.signOut)
+ .id(connection.activeAccountID)
+ #endif
+ } else {
+ NativeAccountsView("Oficina Sheets", systemImage: "tablecells", connection: connection)
+ }
+ }.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
+ .tint(.green)
+ #if os(macOS)
+ .frame(minWidth: 760, minHeight: 600)
+ #endif
+ }
+}
diff --git a/native/apple/Sources/SheetsApp/SheetsApp.swift b/native/apple/Sources/SheetsApp/SheetsApp.swift
index 508f3a0..0f425da 100644
--- a/native/apple/Sources/SheetsApp/SheetsApp.swift
+++ b/native/apple/Sources/SheetsApp/SheetsApp.swift
@@ -1,25 +1,36 @@
+#if os(macOS) && canImport(RastrilloMacUpdates)
+import RastrilloMacUpdates
+#endif
import RastrilloApple
import SheetsKit
import SwiftUI
@main struct SheetsApp: App {
+ #if os(macOS) && canImport(RastrilloMacUpdates)
+ @State private var updater = NativeUpdater()
+ #endif
#if os(macOS)
+ @State private var windows = SheetsWindows()
@NSApplicationDelegateAdaptor(SheetsApplicationDelegate.self) private var appDelegate
#endif
- @StateObject private var connection = NativeConnection(
- defaultServer: "https://sheets.oficina.works")
var body: some Scene {
- WindowGroup {
- Group {
- if let client = connection.client {
- SheetsWorkspace(client: client, signOut: connection.signOut)
- } else {
- NativeSignInView("Oficina Sheets", systemImage: "tablecells", connection: connection)
- }
- }.tint(.green)
- #if os(macOS)
- .frame(minWidth: 760, minHeight: 600)
+ #if os(macOS)
+ Window(sheetsMacString("Home"), id: "home") { SheetsAccountWindow(windows: windows) }
+ .defaultSize(width: 1000, height: 760)
+ .commands {
+ SheetsWindowCommands(windows: windows)
+ SheetsEditorCommands()
+ #if canImport(RastrilloMacUpdates)
+ NativeUpdateCommands(updater: updater)
#endif
+ }
+ WindowGroup("Sheets", id: "document", for: UUID.self) { $id in
+ SheetsDocumentWindow(windows: windows, id: id)
}
+ .defaultSize(width: 1120, height: 820)
+ .restorationBehavior(.disabled)
+ #else
+ WindowGroup { SheetsAccountWindow() }
+ #endif
}
}
diff --git a/native/apple/Sources/SheetsApp/SheetsDocumentWindow.swift b/native/apple/Sources/SheetsApp/SheetsDocumentWindow.swift
new file mode 100644
index 0000000..125b531
--- /dev/null
+++ b/native/apple/Sources/SheetsApp/SheetsDocumentWindow.swift
@@ -0,0 +1,23 @@
+#if os(macOS)
+import SheetsKit
+import SwiftUI
+
+struct SheetsDocumentWindow: View {
+ let windows: SheetsWindows
+ let id: UUID?
+ @Environment(\.openWindow) private var openWindow
+ var body: some View {
+ if let entry = windows.entries.first(where: { $0.id == id }) {
+ SheetsWorkspace(client: entry.client, signOut: {}, documentID: entry.document)
+ .id(entry.id)
+ .focusedSceneValue(\.sheetsWindowAccount, entry.account)
+ .onAppear { windows.setOpen(entry.id, true) }
+ .onDisappear { windows.setOpen(entry.id, false) }
+ } else {
+ ContentUnavailableView { Label(sheetsMacString("Open a document from Home"), systemImage: "house") } actions: {
+ Button(sheetsMacString("Home")) { openWindow(id: "home") }
+ }
+ }
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsApp/SheetsWindowCommands.swift b/native/apple/Sources/SheetsApp/SheetsWindowCommands.swift
new file mode 100644
index 0000000..28f2105
--- /dev/null
+++ b/native/apple/Sources/SheetsApp/SheetsWindowCommands.swift
@@ -0,0 +1,43 @@
+#if os(macOS)
+import SwiftUI
+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
+ openWindow(id: "home")
+ }.keyboardShortcut("n")
+ Menu(sheetsMacString("Recent")) {
+ ForEach(windows.entries.filter { $0.account == (account ?? windows.activeAccount) }.prefix(20)) { entry in
+ Button(entry.title) {
+ windows.setOpen(entry.id, true)
+ openWindow(id: "document", value: entry.id)
+ }
+ }
+ if !windows.entries.contains(where: { $0.account == (account ?? windows.activeAccount) }) {
+ Text(sheetsMacString("No recent documents"))
+ }
+ }
+ Divider()
+ SheetsDocumentMenu()
+ }
+
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsApp/SheetsWindows.swift b/native/apple/Sources/SheetsApp/SheetsWindows.swift
new file mode 100644
index 0000000..90a953d
--- /dev/null
+++ b/native/apple/Sources/SheetsApp/SheetsWindows.swift
@@ -0,0 +1,48 @@
+#if os(macOS)
+import Foundation
+import Observation
+import RastrilloApple
+import SwiftUI
+
+/// Scene values are opaque IDs, never credentials. Reserve before opening a scene.
+@MainActor @Observable final class SheetsWindows {
+ struct Entry: Identifiable {
+ let id: UUID
+ let account: String
+ let document: UInt64
+ let client: NativeHTTP
+ var title: String
+ var isOpen = false
+ }
+ 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)
+ entry.title = title
+ entry.isOpen = true
+ entries.insert(entry, at: 0)
+ return entry.id
+ }
+ let entry = Entry(id: UUID(), account: account, document: document, client: client, title: title, isOpen: true)
+ entries.insert(entry, at: 0)
+ return entry.id
+ }
+ func updateRecents(_ documents: [(UInt64, String)], account: String, client: NativeHTTP) {
+ for (document, title) in documents {
+ if let index = entries.firstIndex(where: { $0.account == account && $0.client.origin == client.origin && $0.document == document }) {
+ entries[index].title = title
+ } else {
+ entries.append(Entry(id: UUID(), account: account, document: document, client: client, title: title))
+ }
+ }
+ }
+ func setOpen(_ id: UUID, _ value: Bool) {
+ if let index = entries.firstIndex(where: { $0.id == id }) { entries[index].isOpen = value }
+ }
+ func canSignOut(_ account: String?) -> Bool { !entries.contains { $0.account == account && $0.isOpen } }
+}
+extension FocusedValues { @Entry var sheetsWindowAccount: String? }
+#endif
diff --git a/native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings
new file mode 100644
index 0000000..de44d60
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "الحسابات";
+"Actual size" = "الحجم الفعلي";
+"Alignment" = "المحاذاة";
+"Block quote" = "كتلة اقتباس";
+"Bold" = "عريض";
+"Bullet list" = "قائمة نقطية";
+"Cell contents" = "محتوى الخلية";
+"Clear formatting" = "مسح التنسيق";
+"Close this account’s document windows before signing out." = "أغلق نوافذ مستندات هذا الحساب قبل تسجيل الخروج.";
+"Create a document to get started." = "ستظهر المستندات التي تنشئها هنا، بدءًا بالأحدث.";
+"Docs" = "Docs";
+"Document" = "المستند";
+"Document inspector" = "خصائص المستند";
+"Double-click to fit column to contents" = "انقر مرتين لملاءمة العمود للمحتوى";
+"Edit %@" = "تحرير %@";
+"Export" = "تصدير";
+"Fit column to contents" = "ملاءمة العمود للمحتوى";
+"Font" = "الخط";
+"Format" = "تنسيق";
+"Heading %d" = "العنوان %d";
+"Home" = "الرئيسية";
+"Indent" = "زيادة المسافة البادئة";
+"Inline code" = "شيفرة";
+"Inspector" = "خصائص المستند";
+"Italic" = "مائل";
+"Markdown…" = "Markdown";
+"New document" = "مستند جديد";
+"No documents" = "لا توجد مستندات بعد";
+"No recent documents" = "لا توجد مستندات بعد";
+"Normal" = "نص أساسي";
+"Numbered list" = "قائمة مرقّمة";
+"Open a document from Home" = "افتح مستندًا من الرئيسية";
+"Outdent" = "تقليل المسافة البادئة";
+"PDF…" = "PDF";
+"Pages" = "صفحات";
+"Paragraph style" = "نمط الفقرة";
+"Print…" = "طباعة";
+"Recent" = "الأخيرة";
+"Recent documents" = "المستندات الأخيرة";
+"Recent spreadsheets" = "جداول البيانات الأخيرة";
+"Retry" = "إعادة المحاولة";
+"Save" = "حفظ";
+"Select column %@" = "تحديد العمود %@";
+"Select row %d" = "تحديد الصف %d";
+"Shared" = "تمت مشاركتها معك";
+"Share…" = "مشاركة";
+"Sign out" = "تسجيل الخروج";
+"Size" = "الحجم";
+"Zoom" = "التكبير";
+"New spreadsheet" = "جدول بيانات جديد";
+"Rename…" = "إعادة تسمية…";
+"Export workbook…" = "تصدير المصنف…";
+"Version history…" = "سجل الإصدارات…";
+"Undo" = "تراجع";
+"Redo" = "إعادة";
+"Cut" = "قص";
+"Copy" = "نسخ";
+"Paste" = "لصق";
+"Select all" = "تحديد الكل";
+"Clear contents" = "مسح المحتويات";
+"Fill down" = "تعبئة لأسفل";
+"Fill right" = "تعبئة لليمين";
+"Row and column sizes…" = "أحجام الصفوف والأعمدة…";
+"Insert" = "إدراج";
+"Sheet" = "ورقة";
+"Sort and filter…" = "فرز وتصفية…";
+"Comments…" = "تعليقات…";
+"Refresh" = "تحديث";
+"Import workbook" = "استيراد مصنف";
+"Search spreadsheets" = "البحث في جداول البيانات";
+
+"The server did not confirm the resize." = "لم يؤكد الخادم تغيير الحجم.";
diff --git a/native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings
new file mode 100644
index 0000000..2184398
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "অ্যাকাউন্ট";
+"Actual size" = "প্রকৃত আকার";
+"Alignment" = "প্রান্তিককরণ";
+"Block quote" = "উদ্ধৃতির ব্লক";
+"Bold" = "গাঢ়";
+"Bullet list" = "বুলেটযুক্ত তালিকা";
+"Cell contents" = "ঘরের বিষয়বস্তু";
+"Clear formatting" = "বিন্যাস মুছুন";
+"Close this account’s document windows before signing out." = "সাইন আউট করার আগে এই অ্যাকাউন্টের নথির উইন্ডোগুলি বন্ধ করুন।";
+"Create a document to get started." = "আপনার তৈরি নথি এখানে দেখা যাবে, নতুনগুলো আগে।";
+"Docs" = "Docs";
+"Document" = "নথি";
+"Document inspector" = "নথি পরিদর্শক";
+"Double-click to fit column to contents" = "বিষয়বস্তু অনুযায়ী কলাম মাপতে দুবার ক্লিক করুন";
+"Edit %@" = "%@ সম্পাদনা করুন";
+"Export" = "রপ্তানি";
+"Fit column to contents" = "বিষয়বস্তু অনুযায়ী কলাম মাপুন";
+"Font" = "ফন্ট";
+"Format" = "বিন্যাস";
+"Heading %d" = "শিরোনাম %d";
+"Home" = "হোম";
+"Indent" = "ইন্ডেন্ট বাড়ান";
+"Inline code" = "কোড";
+"Inspector" = "নথি পরিদর্শক";
+"Italic" = "তির্যক";
+"Markdown…" = "Markdown";
+"New document" = "নতুন নথি";
+"No documents" = "এখনও কোনো নথি নেই";
+"No recent documents" = "এখনও কোনো নথি নেই";
+"Normal" = "মূল পাঠ্য";
+"Numbered list" = "সংখ্যাযুক্ত তালিকা";
+"Open a document from Home" = "হোম থেকে একটি নথি খুলুন";
+"Outdent" = "ইন্ডেন্ট কমান";
+"PDF…" = "PDF";
+"Pages" = "পৃষ্ঠাভিত্তিক";
+"Paragraph style" = "অনুচ্ছেদের শৈলী";
+"Print…" = "প্রিন্ট করুন";
+"Recent" = "সাম্প্রতিক";
+"Recent documents" = "সাম্প্রতিক নথি";
+"Recent spreadsheets" = "সাম্প্রতিক স্প্রেডশিট";
+"Retry" = "আবার চেষ্টা করুন";
+"Save" = "সংরক্ষণ করুন";
+"Select column %@" = "কলাম %@ নির্বাচন করুন";
+"Select row %d" = "সারি %d নির্বাচন করুন";
+"Shared" = "আপনার সঙ্গে শেয়ার করা";
+"Share…" = "শেয়ার করুন";
+"Sign out" = "সাইন আউট করুন";
+"Size" = "আকার";
+"Zoom" = "জুম";
+"New spreadsheet" = "নতুন স্প্রেডশিট";
+"Rename…" = "নাম বদলান…";
+"Export workbook…" = "ওয়ার্কবুক রপ্তানি করুন…";
+"Version history…" = "সংস্করণের ইতিহাস…";
+"Undo" = "পূর্বাবস্থায় ফেরান";
+"Redo" = "আবার করুন";
+"Cut" = "কাটুন";
+"Copy" = "কপি করুন";
+"Paste" = "পেস্ট করুন";
+"Select all" = "সব নির্বাচন করুন";
+"Clear contents" = "বিষয়বস্তু মুছুন";
+"Fill down" = "নিচে পূরণ করুন";
+"Fill right" = "ডানে পূরণ করুন";
+"Row and column sizes…" = "সারি ও কলামের মাপ…";
+"Insert" = "ঢোকান";
+"Sheet" = "শিট";
+"Sort and filter…" = "সাজান ও ফিল্টার করুন…";
+"Comments…" = "মন্তব্য…";
+"Refresh" = "রিফ্রেশ করুন";
+"Import workbook" = "ওয়ার্কবুক আমদানি করুন";
+"Search spreadsheets" = "স্প্রেডশিট খুঁজুন";
+
+"The server did not confirm the resize." = "সার্ভার আকার পরিবর্তন নিশ্চিত করেনি।";
diff --git a/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings
new file mode 100644
index 0000000..b14897d
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "Accounts";
+"Actual size" = "Actual size";
+"Alignment" = "Alignment";
+"Block quote" = "Block quote";
+"Bold" = "Bold";
+"Bullet list" = "Bulleted list";
+"Cell contents" = "Cell contents";
+"Clear formatting" = "Clear formatting";
+"Close this account’s document windows before signing out." = "Close this account’s document windows before signing out.";
+"Create a document to get started." = "Documents you create will show up here, newest first.";
+"Docs" = "Docs";
+"Document" = "Document";
+"Document inspector" = "Document inspector";
+"Double-click to fit column to contents" = "Double-click to fit column to contents";
+"Edit %@" = "Edit %@";
+"Export" = "Export";
+"Fit column to contents" = "Fit column to contents";
+"Font" = "Font";
+"Format" = "Format";
+"Heading %d" = "Heading %d";
+"Home" = "Home";
+"Indent" = "Increase indent";
+"Inline code" = "Code";
+"Inspector" = "Document inspector";
+"Italic" = "Italic";
+"Markdown…" = "Markdown";
+"New document" = "New document";
+"No documents" = "No documents yet";
+"No recent documents" = "No documents yet";
+"Normal" = "Body text";
+"Numbered list" = "Numbered list";
+"Open a document from Home" = "Open a document from Home";
+"Outdent" = "Decrease indent";
+"PDF…" = "PDF";
+"Pages" = "Paged";
+"Paragraph style" = "Paragraph style";
+"Print…" = "Print";
+"Recent" = "Recent";
+"Recent documents" = "Recent documents";
+"Recent spreadsheets" = "Recent spreadsheets";
+"Retry" = "Try again";
+"Save" = "Save";
+"Select column %@" = "Select column %@";
+"Select row %d" = "Select row %d";
+"Shared" = "Shared with you";
+"Share…" = "Share";
+"Sign out" = "Sign out";
+"Size" = "Size";
+"Zoom" = "Zoom";
+"New spreadsheet" = "New spreadsheet";
+"Rename…" = "Rename…";
+"Export workbook…" = "Export workbook…";
+"Version history…" = "Version history…";
+"Undo" = "Undo";
+"Redo" = "Redo";
+"Cut" = "Cut";
+"Copy" = "Copy";
+"Paste" = "Paste";
+"Select all" = "Select all";
+"Clear contents" = "Clear contents";
+"Fill down" = "Fill down";
+"Fill right" = "Fill right";
+"Row and column sizes…" = "Row and column sizes…";
+"Insert" = "Insert";
+"Sheet" = "Sheet";
+"Sort and filter…" = "Sort and filter…";
+"Comments…" = "Comments…";
+"Refresh" = "Refresh";
+"Import workbook" = "Import workbook";
+"Search spreadsheets" = "Search spreadsheets";
+
+"The server did not confirm the resize." = "The server did not confirm the resize.";
diff --git a/native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings
new file mode 100644
index 0000000..58a89e9
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "Cuentas";
+"Actual size" = "Tamaño real";
+"Alignment" = "Alineación";
+"Block quote" = "Cita en bloque";
+"Bold" = "Negrita";
+"Bullet list" = "Lista con viñetas";
+"Cell contents" = "Contenido de la celda";
+"Clear formatting" = "Borrar formato";
+"Close this account’s document windows before signing out." = "Cierra las ventanas de documentos de esta cuenta antes de cerrar sesión.";
+"Create a document to get started." = "Los documentos que crees aparecerán aquí, empezando por los más recientes.";
+"Docs" = "Docs";
+"Document" = "Documento";
+"Document inspector" = "Inspector del documento";
+"Double-click to fit column to contents" = "Haz doble clic para ajustar la columna al contenido";
+"Edit %@" = "Editar %@";
+"Export" = "Exportar";
+"Fit column to contents" = "Ajustar columna al contenido";
+"Font" = "Fuente";
+"Format" = "Formato";
+"Heading %d" = "Título %d";
+"Home" = "Inicio";
+"Indent" = "Aumentar sangría";
+"Inline code" = "Código";
+"Inspector" = "Inspector del documento";
+"Italic" = "Cursiva";
+"Markdown…" = "Markdown";
+"New document" = "Nuevo documento";
+"No documents" = "Aún no hay documentos";
+"No recent documents" = "Aún no hay documentos";
+"Normal" = "Texto del cuerpo";
+"Numbered list" = "Lista numerada";
+"Open a document from Home" = "Abre un documento desde Inicio";
+"Outdent" = "Reducir sangría";
+"PDF…" = "PDF";
+"Pages" = "Por páginas";
+"Paragraph style" = "Estilo de párrafo";
+"Print…" = "Imprimir";
+"Recent" = "Recientes";
+"Recent documents" = "Documentos recientes";
+"Recent spreadsheets" = "Hojas de cálculo recientes";
+"Retry" = "Reintentar";
+"Save" = "Guardar";
+"Select column %@" = "Seleccionar columna %@";
+"Select row %d" = "Seleccionar fila %d";
+"Shared" = "Compartidos contigo";
+"Share…" = "Compartir";
+"Sign out" = "Cerrar sesión";
+"Size" = "Tamaño";
+"Zoom" = "Zoom";
+"New spreadsheet" = "Nueva hoja de cálculo";
+"Rename…" = "Renombrar…";
+"Export workbook…" = "Exportar libro…";
+"Version history…" = "Historial de versiones…";
+"Undo" = "Deshacer";
+"Redo" = "Rehacer";
+"Cut" = "Cortar";
+"Copy" = "Copiar";
+"Paste" = "Pegar";
+"Select all" = "Seleccionar todo";
+"Clear contents" = "Borrar contenido";
+"Fill down" = "Rellenar hacia abajo";
+"Fill right" = "Rellenar hacia la derecha";
+"Row and column sizes…" = "Tamaños de filas y columnas…";
+"Insert" = "Insertar";
+"Sheet" = "Hoja";
+"Sort and filter…" = "Ordenar y filtrar…";
+"Comments…" = "Comentarios…";
+"Refresh" = "Actualizar";
+"Import workbook" = "Importar libro";
+"Search spreadsheets" = "Buscar hojas de cálculo";
+
+"The server did not confirm the resize." = "El servidor no confirmó el cambio de tamaño.";
diff --git a/native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings
new file mode 100644
index 0000000..81724d5
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "Cuntais";
+"Actual size" = "Fíormhéid";
+"Alignment" = "Ailíniú";
+"Block quote" = "Athfhriotal i mbloc";
+"Bold" = "Trom";
+"Bullet list" = "Liosta le hurchair";
+"Cell contents" = "Ábhar na cille";
+"Clear formatting" = "Glan an fhormáidiú";
+"Close this account’s document windows before signing out." = "Dún fuinneoga cáipéisí an chuntais seo sula síníonn tú amach.";
+"Create a document to get started." = "Beidh na doiciméid a chruthaíonn tú anseo, na cinn is nua ar dtús.";
+"Docs" = "Docs";
+"Document" = "Cáipéis";
+"Document inspector" = "Cigire cáipéise";
+"Double-click to fit column to contents" = "Déchliceáil chun an colún a oiriúnú don ábhar";
+"Edit %@" = "Cuir %@ in eagar";
+"Export" = "Easpórtáil";
+"Fit column to contents" = "Oiriúnaigh an colún don ábhar";
+"Font" = "Cló";
+"Format" = "Formáid";
+"Heading %d" = "Ceannteideal %d";
+"Home" = "Baile";
+"Indent" = "Méadaigh an eangú";
+"Inline code" = "Cód";
+"Inspector" = "Cigire cáipéise";
+"Italic" = "Iodálach";
+"Markdown…" = "Markdown";
+"New document" = "Doiciméad nua";
+"No documents" = "Níl aon doiciméid ann fós";
+"No recent documents" = "Níl aon doiciméid ann fós";
+"Normal" = "Téacs coirp";
+"Numbered list" = "Liosta uimhrithe";
+"Open a document from Home" = "Oscail cáipéis ón mBaile";
+"Outdent" = "Laghdaigh an eangú";
+"PDF…" = "PDF";
+"Pages" = "Leathanaigh";
+"Paragraph style" = "Stíl ailt";
+"Print…" = "Priontáil";
+"Recent" = "Le déanaí";
+"Recent documents" = "Cáipéisí le déanaí";
+"Recent spreadsheets" = "Scarbhileoga le déanaí";
+"Retry" = "Bain triail eile as";
+"Save" = "Sábháil";
+"Select column %@" = "Roghnaigh colún %@";
+"Select row %d" = "Roghnaigh ró %d";
+"Shared" = "Comhroinnte leat";
+"Share…" = "Comhroinn";
+"Sign out" = "Sínigh amach";
+"Size" = "Méid";
+"Zoom" = "Súmáil";
+"New spreadsheet" = "Scarbhileog nua";
+"Rename…" = "Athainmnigh…";
+"Export workbook…" = "Easpórtáil leabhar oibre…";
+"Version history…" = "Stair na leaganacha…";
+"Undo" = "Cealaigh";
+"Redo" = "Athdhéan";
+"Cut" = "Gearr";
+"Copy" = "Cóipeáil";
+"Paste" = "Greamaigh";
+"Select all" = "Roghnaigh uile";
+"Clear contents" = "Glan an t-ábhar";
+"Fill down" = "Líon síos";
+"Fill right" = "Líon ar dheis";
+"Row and column sizes…" = "Méideanna rónna agus colún…";
+"Insert" = "Ionsáigh";
+"Sheet" = "Bileog";
+"Sort and filter…" = "Sórtáil agus scag…";
+"Comments…" = "Nótaí tráchta…";
+"Refresh" = "Athnuaigh";
+"Import workbook" = "Iompórtáil leabhar oibre";
+"Search spreadsheets" = "Cuardaigh scarbhileoga";
+
+"The server did not confirm the resize." = "Níor dheimhnigh an freastalaí an t-athrú méide.";
diff --git a/native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings
new file mode 100644
index 0000000..f80cc0c
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "खाते";
+"Actual size" = "वास्तविक आकार";
+"Alignment" = "संरेखण";
+"Block quote" = "ब्लॉक उद्धरण";
+"Bold" = "बोल्ड";
+"Bullet list" = "बुलेट वाली सूची";
+"Cell contents" = "सेल की सामग्री";
+"Clear formatting" = "फ़ॉर्मैटिंग हटाएँ";
+"Close this account’s document windows before signing out." = "साइन आउट करने से पहले इस खाते की दस्तावेज़ विंडो बंद करें।";
+"Create a document to get started." = "आपके बनाए दस्तावेज़ यहाँ दिखेंगे, सबसे नए पहले।";
+"Docs" = "Docs";
+"Document" = "दस्तावेज़";
+"Document inspector" = "दस्तावेज़ निरीक्षक";
+"Double-click to fit column to contents" = "सामग्री के अनुसार आकार बदलने के लिए डबल-क्लिक करें";
+"Edit %@" = "%@ संपादित करें";
+"Export" = "निर्यात";
+"Fit column to contents" = "सामग्री के अनुसार स्तंभ का आकार बदलें";
+"Font" = "फ़ॉन्ट";
+"Format" = "फ़ॉर्मैट";
+"Heading %d" = "शीर्षक %d";
+"Home" = "होम";
+"Indent" = "इंडेंट बढ़ाएँ";
+"Inline code" = "कोड";
+"Inspector" = "दस्तावेज़ निरीक्षक";
+"Italic" = "इटैलिक";
+"Markdown…" = "Markdown";
+"New document" = "नया दस्तावेज़";
+"No documents" = "अभी कोई दस्तावेज़ नहीं";
+"No recent documents" = "अभी कोई दस्तावेज़ नहीं";
+"Normal" = "मुख्य पाठ";
+"Numbered list" = "क्रमांकित सूची";
+"Open a document from Home" = "होम से दस्तावेज़ खोलें";
+"Outdent" = "इंडेंट घटाएँ";
+"PDF…" = "PDF";
+"Pages" = "पृष्ठों में";
+"Paragraph style" = "अनुच्छेद शैली";
+"Print…" = "प्रिंट करें";
+"Recent" = "हाल के";
+"Recent documents" = "हाल के दस्तावेज़";
+"Recent spreadsheets" = "हाल की स्प्रेडशीट";
+"Retry" = "फिर कोशिश करें";
+"Save" = "सहेजें";
+"Select column %@" = "स्तंभ %@ चुनें";
+"Select row %d" = "पंक्ति %d चुनें";
+"Shared" = "आपके साथ शेयर किए गए";
+"Share…" = "शेयर करें";
+"Sign out" = "साइन आउट करें";
+"Size" = "आकार";
+"Zoom" = "ज़ूम";
+"New spreadsheet" = "नई स्प्रेडशीट";
+"Rename…" = "नाम बदलें…";
+"Export workbook…" = "कार्यपुस्तिका निर्यात करें…";
+"Version history…" = "संस्करण इतिहास…";
+"Undo" = "पूर्ववत करें";
+"Redo" = "फिर करें";
+"Cut" = "काटें";
+"Copy" = "कॉपी करें";
+"Paste" = "चिपकाएँ";
+"Select all" = "सभी चुनें";
+"Clear contents" = "सामग्री मिटाएँ";
+"Fill down" = "नीचे भरें";
+"Fill right" = "दाईं ओर भरें";
+"Row and column sizes…" = "पंक्ति और स्तंभ के आकार…";
+"Insert" = "सम्मिलित करें";
+"Sheet" = "शीट";
+"Sort and filter…" = "क्रमबद्ध और फ़िल्टर करें…";
+"Comments…" = "टिप्पणियाँ…";
+"Refresh" = "रीफ़्रेश करें";
+"Import workbook" = "कार्यपुस्तिका आयात करें";
+"Search spreadsheets" = "स्प्रेडशीट खोजें";
+
+"The server did not confirm the resize." = "सर्वर ने आकार बदलने की पुष्टि नहीं की।";
diff --git a/native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings
new file mode 100644
index 0000000..4f9e3da
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "アカウント";
+"Actual size" = "実際のサイズ";
+"Alignment" = "配置";
+"Block quote" = "引用ブロック";
+"Bold" = "太字";
+"Bullet list" = "箇条書き";
+"Cell contents" = "セルの内容";
+"Clear formatting" = "書式をクリア";
+"Close this account’s document windows before signing out." = "サインアウトする前に、このアカウントの書類ウインドウを閉じてください。";
+"Create a document to get started." = "作成したドキュメントが新しい順にここに表示されます。";
+"Docs" = "Docs";
+"Document" = "書類";
+"Document inspector" = "書類インスペクタ";
+"Double-click to fit column to contents" = "ダブルクリックで列幅を内容に合わせる";
+"Edit %@" = "%@ を編集";
+"Export" = "書き出す";
+"Fit column to contents" = "列幅を内容に合わせる";
+"Font" = "フォント";
+"Format" = "書式";
+"Heading %d" = "見出し %d";
+"Home" = "ホーム";
+"Indent" = "インデントを増やす";
+"Inline code" = "コード";
+"Inspector" = "書類インスペクタ";
+"Italic" = "斜体";
+"Markdown…" = "Markdown";
+"New document" = "新規ドキュメント";
+"No documents" = "ドキュメントはまだありません";
+"No recent documents" = "ドキュメントはまだありません";
+"Normal" = "本文";
+"Numbered list" = "番号付きリスト";
+"Open a document from Home" = "ホームから書類を開く";
+"Outdent" = "インデントを減らす";
+"PDF…" = "PDF";
+"Pages" = "ページ区切り";
+"Paragraph style" = "段落スタイル";
+"Print…" = "プリント";
+"Recent" = "最近使った項目";
+"Recent documents" = "最近の書類";
+"Recent spreadsheets" = "最近のスプレッドシート";
+"Retry" = "再試行";
+"Save" = "保存";
+"Select column %@" = "列 %@ を選択";
+"Select row %d" = "行 %d を選択";
+"Shared" = "あなたと共有";
+"Share…" = "共有";
+"Sign out" = "サインアウト";
+"Size" = "サイズ";
+"Zoom" = "ズーム";
+"New spreadsheet" = "新規スプレッドシート";
+"Rename…" = "名前を変更…";
+"Export workbook…" = "ブックを書き出す…";
+"Version history…" = "バージョン履歴…";
+"Undo" = "取り消す";
+"Redo" = "やり直す";
+"Cut" = "カット";
+"Copy" = "コピー";
+"Paste" = "ペースト";
+"Select all" = "すべてを選択";
+"Clear contents" = "内容を消去";
+"Fill down" = "下方向へコピー";
+"Fill right" = "右方向へコピー";
+"Row and column sizes…" = "行と列のサイズ…";
+"Insert" = "挿入";
+"Sheet" = "シート";
+"Sort and filter…" = "並べ替えとフィルター…";
+"Comments…" = "コメント…";
+"Refresh" = "更新";
+"Import workbook" = "ブックを読み込む";
+"Search spreadsheets" = "スプレッドシートを検索";
+
+"The server did not confirm the resize." = "サーバーがサイズ変更を確認しませんでした。";
diff --git a/native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings
new file mode 100644
index 0000000..e78f641
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "Contas";
+"Actual size" = "Tamanho real";
+"Alignment" = "Alinhamento";
+"Block quote" = "Citação em bloco";
+"Bold" = "Negrito";
+"Bullet list" = "Lista com marcas";
+"Cell contents" = "Conteúdo da célula";
+"Clear formatting" = "Limpar formatação";
+"Close this account’s document windows before signing out." = "Feche as janelas de documentos desta conta antes de terminar a sessão.";
+"Create a document to get started." = "Os documentos que criar aparecem aqui, começando pelos mais recentes.";
+"Docs" = "Docs";
+"Document" = "Documento";
+"Document inspector" = "Inspetor do documento";
+"Double-click to fit column to contents" = "Clique duas vezes para ajustar a coluna ao conteúdo";
+"Edit %@" = "Editar %@";
+"Export" = "Exportar";
+"Fit column to contents" = "Ajustar coluna ao conteúdo";
+"Font" = "Tipo de letra";
+"Format" = "Formato";
+"Heading %d" = "Título %d";
+"Home" = "Início";
+"Indent" = "Aumentar avanço";
+"Inline code" = "Código";
+"Inspector" = "Inspetor do documento";
+"Italic" = "Itálico";
+"Markdown…" = "Markdown";
+"New document" = "Novo documento";
+"No documents" = "Ainda não há documentos";
+"No recent documents" = "Ainda não há documentos";
+"Normal" = "Corpo do texto";
+"Numbered list" = "Lista numerada";
+"Open a document from Home" = "Abra um documento no Início";
+"Outdent" = "Diminuir avanço";
+"PDF…" = "PDF";
+"Pages" = "Paginado";
+"Paragraph style" = "Estilo de parágrafo";
+"Print…" = "Imprimir";
+"Recent" = "Recentes";
+"Recent documents" = "Documentos recentes";
+"Recent spreadsheets" = "Folhas de cálculo recentes";
+"Retry" = "Tentar novamente";
+"Save" = "Guardar";
+"Select column %@" = "Selecionar coluna %@";
+"Select row %d" = "Selecionar linha %d";
+"Shared" = "Partilhados consigo";
+"Share…" = "Partilhar";
+"Sign out" = "Terminar sessão";
+"Size" = "Tamanho";
+"Zoom" = "Zoom";
+"New spreadsheet" = "Nova planilha";
+"Rename…" = "Renomear…";
+"Export workbook…" = "Exportar pasta de trabalho…";
+"Version history…" = "Histórico de versões…";
+"Undo" = "Desfazer";
+"Redo" = "Refazer";
+"Cut" = "Recortar";
+"Copy" = "Copiar";
+"Paste" = "Colar";
+"Select all" = "Selecionar tudo";
+"Clear contents" = "Limpar conteúdo";
+"Fill down" = "Preencher para baixo";
+"Fill right" = "Preencher à direita";
+"Row and column sizes…" = "Tamanhos de linhas e colunas…";
+"Insert" = "Inserir";
+"Sheet" = "Planilha";
+"Sort and filter…" = "Ordenar e filtrar…";
+"Comments…" = "Comentários…";
+"Refresh" = "Atualizar";
+"Import workbook" = "Importar pasta de trabalho";
+"Search spreadsheets" = "Pesquisar planilhas";
+
+"The server did not confirm the resize." = "O servidor não confirmou o redimensionamento.";
diff --git a/native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings
new file mode 100644
index 0000000..f2dffdc
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "Учётные записи";
+"Actual size" = "Фактический размер";
+"Alignment" = "Выравнивание";
+"Block quote" = "Блок цитаты";
+"Bold" = "Полужирный";
+"Bullet list" = "Маркированный список";
+"Cell contents" = "Содержимое ячейки";
+"Clear formatting" = "Очистить форматирование";
+"Close this account’s document windows before signing out." = "Перед выходом закройте окна документов этой учётной записи.";
+"Create a document to get started." = "Созданные вами документы появятся здесь, начиная с самых новых.";
+"Docs" = "Docs";
+"Document" = "Документ";
+"Document inspector" = "Инспектор документа";
+"Double-click to fit column to contents" = "Дважды щёлкните для подгонки ширины столбца";
+"Edit %@" = "Изменить %@";
+"Export" = "Экспорт";
+"Fit column to contents" = "Подогнать ширину столбца";
+"Font" = "Шрифт";
+"Format" = "Формат";
+"Heading %d" = "Заголовок %d";
+"Home" = "Главная";
+"Indent" = "Увеличить отступ";
+"Inline code" = "Код";
+"Inspector" = "Инспектор документа";
+"Italic" = "Курсив";
+"Markdown…" = "Markdown";
+"New document" = "Новый документ";
+"No documents" = "Документов пока нет";
+"No recent documents" = "Документов пока нет";
+"Normal" = "Основной текст";
+"Numbered list" = "Нумерованный список";
+"Open a document from Home" = "Откройте документ на главной";
+"Outdent" = "Уменьшить отступ";
+"PDF…" = "PDF";
+"Pages" = "Постраничный";
+"Paragraph style" = "Стиль абзаца";
+"Print…" = "Печать";
+"Recent" = "Недавние";
+"Recent documents" = "Недавние документы";
+"Recent spreadsheets" = "Недавние таблицы";
+"Retry" = "Повторить попытку";
+"Save" = "Сохранить";
+"Select column %@" = "Выбрать столбец %@";
+"Select row %d" = "Выбрать строку %d";
+"Shared" = "Доступные вам";
+"Share…" = "Поделиться";
+"Sign out" = "Выйти";
+"Size" = "Размер";
+"Zoom" = "Масштаб";
+"New spreadsheet" = "Новая таблица";
+"Rename…" = "Переименовать…";
+"Export workbook…" = "Экспорт книги…";
+"Version history…" = "История версий…";
+"Undo" = "Отменить";
+"Redo" = "Повторить";
+"Cut" = "Вырезать";
+"Copy" = "Копировать";
+"Paste" = "Вставить";
+"Select all" = "Выбрать всё";
+"Clear contents" = "Очистить содержимое";
+"Fill down" = "Заполнить вниз";
+"Fill right" = "Заполнить вправо";
+"Row and column sizes…" = "Размеры строк и столбцов…";
+"Insert" = "Вставка";
+"Sheet" = "Лист";
+"Sort and filter…" = "Сортировка и фильтр…";
+"Comments…" = "Комментарии…";
+"Refresh" = "Обновить";
+"Import workbook" = "Импорт книги";
+"Search spreadsheets" = "Поиск таблиц";
+
+"The server did not confirm the resize." = "Сервер не подтвердил изменение размера.";
diff --git a/native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings
new file mode 100644
index 0000000..e4531f4
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "Tài khoản";
+"Actual size" = "Kích thước thực";
+"Alignment" = "Căn chỉnh";
+"Block quote" = "Khối trích dẫn";
+"Bold" = "Đậm";
+"Bullet list" = "Danh sách dấu đầu dòng";
+"Cell contents" = "Nội dung ô";
+"Clear formatting" = "Xóa định dạng";
+"Close this account’s document windows before signing out." = "Đóng các cửa sổ tài liệu của tài khoản này trước khi đăng xuất.";
+"Create a document to get started." = "Tài liệu bạn tạo sẽ xuất hiện ở đây, mới nhất trước.";
+"Docs" = "Docs";
+"Document" = "Tài liệu";
+"Document inspector" = "Thuộc tính tài liệu";
+"Double-click to fit column to contents" = "Nhấp đúp để khớp cột với nội dung";
+"Edit %@" = "Sửa %@";
+"Export" = "Xuất";
+"Fit column to contents" = "Khớp cột với nội dung";
+"Font" = "Phông chữ";
+"Format" = "Định dạng";
+"Heading %d" = "Tiêu đề %d";
+"Home" = "Trang chủ";
+"Indent" = "Tăng thụt lề";
+"Inline code" = "Mã";
+"Inspector" = "Thuộc tính tài liệu";
+"Italic" = "Nghiêng";
+"Markdown…" = "Markdown";
+"New document" = "Tài liệu mới";
+"No documents" = "Chưa có tài liệu";
+"No recent documents" = "Chưa có tài liệu";
+"Normal" = "Văn bản nội dung";
+"Numbered list" = "Danh sách đánh số";
+"Open a document from Home" = "Mở tài liệu từ Trang chủ";
+"Outdent" = "Giảm thụt lề";
+"PDF…" = "PDF";
+"Pages" = "Theo trang";
+"Paragraph style" = "Kiểu đoạn văn";
+"Print…" = "In";
+"Recent" = "Gần đây";
+"Recent documents" = "Tài liệu gần đây";
+"Recent spreadsheets" = "Bảng tính gần đây";
+"Retry" = "Thử lại";
+"Save" = "Lưu";
+"Select column %@" = "Chọn cột %@";
+"Select row %d" = "Chọn hàng %d";
+"Shared" = "Được chia sẻ với bạn";
+"Share…" = "Chia sẻ";
+"Sign out" = "Đăng xuất";
+"Size" = "Kích cỡ";
+"Zoom" = "Thu phóng";
+"New spreadsheet" = "Bảng tính mới";
+"Rename…" = "Đổi tên…";
+"Export workbook…" = "Xuất sổ làm việc…";
+"Version history…" = "Lịch sử phiên bản…";
+"Undo" = "Hoàn tác";
+"Redo" = "Làm lại";
+"Cut" = "Cắt";
+"Copy" = "Sao chép";
+"Paste" = "Dán";
+"Select all" = "Chọn tất cả";
+"Clear contents" = "Xóa nội dung";
+"Fill down" = "Điền xuống";
+"Fill right" = "Điền sang phải";
+"Row and column sizes…" = "Kích thước hàng và cột…";
+"Insert" = "Chèn";
+"Sheet" = "Trang tính";
+"Sort and filter…" = "Sắp xếp và lọc…";
+"Comments…" = "Bình luận…";
+"Refresh" = "Làm mới";
+"Import workbook" = "Nhập sổ làm việc";
+"Search spreadsheets" = "Tìm bảng tính";
+
+"The server did not confirm the resize." = "Máy chủ chưa xác nhận thay đổi kích thước.";
diff --git a/native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings
new file mode 100644
index 0000000..bf496f8
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "帳戶";
+"Actual size" = "實際大小";
+"Alignment" = "對齊";
+"Block quote" = "引文區塊";
+"Bold" = "粗體";
+"Bullet list" = "項目符號清單";
+"Cell contents" = "儲存格內容";
+"Clear formatting" = "清除格式";
+"Close this account’s document windows before signing out." = "登出前請先關閉此帳戶嘅文件視窗。";
+"Create a document to get started." = "你建立嘅文件會喺呢度顯示,最新嘅排最前。";
+"Docs" = "Docs";
+"Document" = "文件";
+"Document inspector" = "文件檢查器";
+"Double-click to fit column to contents" = "按兩下以按內容調整欄寬";
+"Edit %@" = "編輯 %@";
+"Export" = "輸出";
+"Fit column to contents" = "按內容調整欄寬";
+"Font" = "字體";
+"Format" = "格式";
+"Heading %d" = "標題 %d";
+"Home" = "主頁";
+"Indent" = "增加縮排";
+"Inline code" = "程式碼";
+"Inspector" = "文件檢查器";
+"Italic" = "斜體";
+"Markdown…" = "Markdown";
+"New document" = "新增文件";
+"No documents" = "仲未有文件";
+"No recent documents" = "仲未有文件";
+"Normal" = "內文";
+"Numbered list" = "編號清單";
+"Open a document from Home" = "由主頁開啟文件";
+"Outdent" = "減少縮排";
+"PDF…" = "PDF";
+"Pages" = "分頁";
+"Paragraph style" = "段落樣式";
+"Print…" = "列印";
+"Recent" = "最近項目";
+"Recent documents" = "最近文件";
+"Recent spreadsheets" = "最近試算表";
+"Retry" = "再試一次";
+"Save" = "儲存";
+"Select column %@" = "選取 %@ 欄";
+"Select row %d" = "選取第 %d 行";
+"Shared" = "同你分享嘅文件";
+"Share…" = "分享";
+"Sign out" = "登出";
+"Size" = "大小";
+"Zoom" = "縮放";
+"New spreadsheet" = "新增試算表";
+"Rename…" = "重新命名…";
+"Export workbook…" = "匯出活頁簿…";
+"Version history…" = "版本記錄…";
+"Undo" = "還原";
+"Redo" = "重做";
+"Cut" = "剪下";
+"Copy" = "複製";
+"Paste" = "貼上";
+"Select all" = "全選";
+"Clear contents" = "清除內容";
+"Fill down" = "向下填滿";
+"Fill right" = "向右填滿";
+"Row and column sizes…" = "列與欄大小…";
+"Insert" = "插入";
+"Sheet" = "工作表";
+"Sort and filter…" = "排序及篩選…";
+"Comments…" = "留言…";
+"Refresh" = "重新整理";
+"Import workbook" = "匯入活頁簿";
+"Search spreadsheets" = "搜尋試算表";
+
+"The server did not confirm the resize." = "伺服器未確認大小調整。";
diff --git a/native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings
new file mode 100644
index 0000000..43eebfb
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings
@@ -0,0 +1,72 @@
+"Accounts" = "账户";
+"Actual size" = "实际大小";
+"Alignment" = "对齐";
+"Block quote" = "块引用";
+"Bold" = "粗体";
+"Bullet list" = "项目符号列表";
+"Cell contents" = "单元格内容";
+"Clear formatting" = "清除格式";
+"Close this account’s document windows before signing out." = "退出登录前请关闭此账户的文档窗口。";
+"Create a document to get started." = "您创建的文档会显示在这里,最新的排在最前面。";
+"Docs" = "Docs";
+"Document" = "文档";
+"Document inspector" = "文档检查器";
+"Double-click to fit column to contents" = "双击以根据内容调整列宽";
+"Edit %@" = "编辑 %@";
+"Export" = "导出";
+"Fit column to contents" = "根据内容调整列宽";
+"Font" = "字体";
+"Format" = "格式";
+"Heading %d" = "标题 %d";
+"Home" = "主页";
+"Indent" = "增加缩进";
+"Inline code" = "代码";
+"Inspector" = "文档检查器";
+"Italic" = "斜体";
+"Markdown…" = "Markdown";
+"New document" = "新建文档";
+"No documents" = "还没有文档";
+"No recent documents" = "还没有文档";
+"Normal" = "正文";
+"Numbered list" = "编号列表";
+"Open a document from Home" = "从主页打开文档";
+"Outdent" = "减少缩进";
+"PDF…" = "PDF";
+"Pages" = "分页";
+"Paragraph style" = "段落样式";
+"Print…" = "打印";
+"Recent" = "最近项目";
+"Recent documents" = "最近文档";
+"Recent spreadsheets" = "最近电子表格";
+"Retry" = "重试";
+"Save" = "保存";
+"Select column %@" = "选择 %@ 列";
+"Select row %d" = "选择第 %d 行";
+"Shared" = "与您共享";
+"Share…" = "共享";
+"Sign out" = "退出登录";
+"Size" = "大小";
+"Zoom" = "缩放";
+"New spreadsheet" = "新建电子表格";
+"Rename…" = "重命名…";
+"Export workbook…" = "导出工作簿…";
+"Version history…" = "版本历史…";
+"Undo" = "撤销";
+"Redo" = "重做";
+"Cut" = "剪切";
+"Copy" = "复制";
+"Paste" = "粘贴";
+"Select all" = "全选";
+"Clear contents" = "清除内容";
+"Fill down" = "向下填充";
+"Fill right" = "向右填充";
+"Row and column sizes…" = "行和列大小…";
+"Insert" = "插入";
+"Sheet" = "工作表";
+"Sort and filter…" = "排序和筛选…";
+"Comments…" = "评论…";
+"Refresh" = "刷新";
+"Import workbook" = "导入工作簿";
+"Search spreadsheets" = "搜索电子表格";
+
+"The server did not confirm the resize." = "服务器未确认大小调整。";
diff --git a/native/apple/Sources/SheetsKit/SheetAutoSize.swift b/native/apple/Sources/SheetsKit/SheetAutoSize.swift
new file mode 100644
index 0000000..fd9d26f
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetAutoSize.swift
@@ -0,0 +1,18 @@
+#if os(macOS)
+import AppKit
+
+extension SheetModel {
+ /// Fits the loaded grid (the same visible rows used by the editor).
+ func fitColumn(_ column: Int) async {
+ guard (0..<cols).contains(column), mayWrite, !busy, !hasUnconfirmedChanges else { return }
+ var width: CGFloat = 40
+ for (point, cell) in cells where point.c == column {
+ let style = styles[cell.f ?? ""]
+ var font = NSFont.systemFont(ofSize: 13, weight: style?.b == true ? .bold : .regular)
+ if style?.i == true { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
+ width = max(width, (cell.display as NSString).size(withAttributes: [.font: font]).width + 14)
+ }
+ await resize(axis: "col", size: min(1000, Int(ceil(width))), index: column)
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetCell.swift b/native/apple/Sources/SheetsKit/SheetCell.swift
index d3b499e..205f77e 100644
--- a/native/apple/Sources/SheetsKit/SheetCell.swift
+++ b/native/apple/Sources/SheetsKit/SheetCell.swift
@@ -3,20 +3,51 @@ import SwiftUI
struct SheetCell: View {
@Bindable var model: SheetModel
let point: Coordinate
+ @Binding var editing: Coordinate?
+ var select: () -> Void
var beginEditing: () -> Void = {}
+ var endEditing: () -> Void
+ var columnWidth: Double? = nil
+ var rowHeight: Double? = nil
@Environment(\.colorScheme) private var scheme
- private func width(_ col: Int) -> Double { model.sizes.cols?[String(col)] ?? 110 }
- private func height(_ row: Int) -> Double { model.sizes.rows?[String(row)] ?? 32 }
+ private func width(_ col: Int) -> Double { columnWidth ?? model.sizes.cols?[String(col)] ?? 110 }
+ private func height(_ row: Int) -> Double { rowHeight ?? model.sizes.rows?[String(row)] ?? 32 }
var body: some View {
let cell = model.cells[point]
let style = model.styles[cell?.f ?? ""]
let alignment: Alignment =
style?.a == "center"
? .center : style?.a == "right" || (style?.a == nil && cell?.n == true) ? .trailing : .leading
- return Button {
- model.select(point)
- } label: {
- Text(cell?.display ?? "").font(.body.weight(style?.b == true ? .bold : .regular))
+ return Group {
+ #if os(macOS)
+ if editing == point {
+ SheetCellEditor(text: $model.draft, enabled: model.mayWrite && !model.busy,
+ label: String(format: sheetsMacString("Edit %@"), point.ref),
+ commit: { commit(right: $0) }, cancel: {
+ guard !model.busy else { return }
+ model.discardAll(); endEditing()
+ })
+ .padding(.horizontal, 5)
+ .frame(width: width(point.c), height: height(point.r))
+ .background(fill(style))
+ .overlay { Rectangle().strokeBorder(Color.accentColor, lineWidth: 2) }
+ .accessibilityLabel(String(format: sheetsMacString("Edit %@"), point.ref))
+ } else { display(cell, style: style, alignment: alignment) }
+ #else
+ display(cell, style: style, alignment: alignment)
+ #endif
+ }
+ }
+ private func commit(right: Bool) {
+ guard !model.busy, model.selection.start == point else { return }
+ Task {
+ await model.commit(moveRight: right)
+ if !model.hasUnconfirmedChanges { endEditing() }
+ }
+ }
+ private func display(_ cell: Cell?, style: CellStyle?, alignment: Alignment) -> some View {
+ Button(action: select) {
+ Text(cell?.display ?? "").font(.system(size: 13, weight: style?.b == true ? .bold : .regular))
.italic(style?.i == true).lineLimit(1).padding(.horizontal, 5)
.frame(width: width(point.c), height: height(point.r), alignment: alignment)
.foregroundStyle(ink(style)).background(fill(style))
@@ -27,7 +58,7 @@ struct SheetCell: View {
lineWidth: model.selection.contains(point) ? 2 : 0.5)
}
}.buttonStyle(.plain)
- .simultaneousGesture(TapGesture(count: 2).onEnded { if model.mayWrite { beginEditing() } })
+ .simultaneousGesture(TapGesture(count: 2).onEnded { if model.mayWrite, !model.busy, !model.hasUnconfirmedChanges { model.select(point); beginEditing() } })
.accessibilityLabel("\(point.ref), \(cell?.display ?? "Empty")")
.accessibilityValue(style?.lc == true ? "Imported colours have low contrast" : "")
.accessibilityAddTraits(model.selection.contains(point) ? .isSelected : [])
diff --git a/native/apple/Sources/SheetsKit/SheetCellEditor.swift b/native/apple/Sources/SheetsKit/SheetCellEditor.swift
new file mode 100644
index 0000000..162aef2
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetCellEditor.swift
@@ -0,0 +1,73 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+/// Position the field editor's caret as part of gaining focus, before another
+/// keyboard event can replace the character that started cell editing.
+struct SheetCellEditor: NSViewRepresentable {
+ @Binding var text: String
+ let enabled: Bool
+ let label: String
+ let commit: (Bool) -> Void
+ let cancel: () -> Void
+ func makeCoordinator() -> Coordinator { Coordinator(self) }
+ func makeNSView(context: Context) -> CellTextField {
+ let field = CellTextField()
+ field.isBordered = false; field.drawsBackground = false
+ // The cell already draws its selection border around the full cell.
+ field.focusRingType = .none
+ field.font = .systemFont(ofSize: 13)
+ field.delegate = context.coordinator
+ field.stringValue = text
+ field.setAccessibilityLabel(label)
+ return field
+ }
+ func updateNSView(_ field: CellTextField, context: Context) {
+ context.coordinator.parent = self
+ let regainedEditing = enabled && !field.isEnabled
+ field.isEnabled = enabled
+ if field.stringValue != text {
+ field.stringValue = text
+ if let editor = field.currentEditor() as? NSTextView {
+ editor.setSelectedRange(NSRange(location: text.utf16.count, length: 0))
+ }
+ }
+ if regainedEditing { field.window?.makeFirstResponder(field) }
+ }
+ final class CellTextField: NSTextField {
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ guard let window else { return }
+ // The representable must be attached before it can become responder.
+ DispatchQueue.main.async { [weak self, weak window] in
+ guard let self, let window, self.window === window, self.isEnabled else { return }
+ window.makeFirstResponder(self)
+ }
+ }
+ override func becomeFirstResponder() -> Bool {
+ let accepted = super.becomeFirstResponder()
+ if accepted, let editor = currentEditor() as? NSTextView {
+ editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
+ }
+ return accepted
+ }
+ }
+ @MainActor final class Coordinator: NSObject, NSTextFieldDelegate {
+ var parent: SheetCellEditor
+ init(_ parent: SheetCellEditor) { self.parent = parent }
+ func controlTextDidChange(_ notification: Notification) {
+ guard let field = notification.object as? NSTextField else { return }
+ parent.text = field.stringValue
+ }
+ func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
+ switch selector {
+ case #selector(NSResponder.insertNewline(_:)): parent.commit(false)
+ case #selector(NSResponder.insertTab(_:)): parent.commit(true)
+ case #selector(NSResponder.cancelOperation(_:)): parent.cancel()
+ default: return false
+ }
+ return true
+ }
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetEditingControls.swift b/native/apple/Sources/SheetsKit/SheetEditingControls.swift
new file mode 100644
index 0000000..03a50d0
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetEditingControls.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+
+// Draft observation stays in these small views, away from the grid hierarchy.
+struct SheetFormulaBar: View {
+ @Bindable var model: SheetModel
+ var formulaFocused: FocusState<Bool>.Binding
+ var body: some View {
+ HStack {
+ TextField("Cell or range", text: $model.rangeText).frame(width: 110).onSubmit {
+ model.selectRange()
+ }.accessibilityLabel("Cell or range, for example A1:B5")
+ Text("fx").foregroundStyle(.secondary)
+ TextField("Cell contents or formula", text: $model.draft).focused(formulaFocused).disabled(
+ !model.mayWrite || model.busy || !model.failedWrites.isEmpty
+ )
+ .autocorrectionDisabled()
+ #if os(iOS)
+ .textInputAutocapitalization(.never)
+ #endif
+ .onSubmit { Task { await model.commit() } }
+ .onKeyPress(.escape) {
+ model.discard()
+ return .handled
+ }
+ .onKeyPress(.tab) {
+ Task { await model.commit(moveRight: true) }
+ return .handled
+ }
+ if model.dirty {
+ Button(sheetsMacString("Save")) { Task { await model.commit() } }.disabled(model.busy)
+ Button("Discard") { model.discard() }.disabled(model.busy)
+ }
+ }.textFieldStyle(.roundedBorder).padding(8)
+ }
+}
+
+struct SheetStatusBar: View {
+ var model: SheetModel
+ var body: some View {
+ HStack {
+ Text(model.page?.Sheet.TabName ?? "Sheet")
+ Spacer()
+ if model.busy { ProgressView().controlSize(.small) }
+ Text(model.dirty ? "Unsaved cell edit" : model.notice)
+ Text(model.live).foregroundStyle(.secondary)
+ }.font(.caption).padding(8)
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetEditorContext.swift b/native/apple/Sources/SheetsKit/SheetEditorContext.swift
new file mode 100644
index 0000000..331a2d0
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetEditorContext.swift
@@ -0,0 +1,9 @@
+#if os(macOS)
+import SwiftUI
+
+struct SheetEditorContext {
+ let model: SheetModel
+ let panel: Binding<String?>
+}
+extension FocusedValues { @Entry var sheetEditor: SheetEditorContext? }
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetFiles.swift b/native/apple/Sources/SheetsKit/SheetFiles.swift
index da8ef7b..c8245bc 100644
--- a/native/apple/Sources/SheetsKit/SheetFiles.swift
+++ b/native/apple/Sources/SheetsKit/SheetFiles.swift
@@ -34,6 +34,7 @@ struct ImportProgress: Decodable {
struct SheetFiles: View {
@Bindable var model: SheetModel
let importing: Bool
+ var openImported: ((UInt64, String) -> Void)? = nil
@State private var picker = false
@State private var exporter = false
@State private var workbook: WorkbookExport?
@@ -147,7 +148,8 @@ struct SheetFiles: View {
if let id = UInt64(state.location.split(separator: "/").last ?? ""),
let sheet = model.sheets.first(where: { $0.ID == id })
{
- await model.open(sheet)
+ if let openImported { openImported(sheet.ID, sheet.Title) }
+ else { await model.open(sheet) }
}
return
}
diff --git a/native/apple/Sources/SheetsKit/SheetGrid.swift b/native/apple/Sources/SheetsKit/SheetGrid.swift
index 74f3f04..d01be06 100644
--- a/native/apple/Sources/SheetsKit/SheetGrid.swift
+++ b/native/apple/Sources/SheetsKit/SheetGrid.swift
@@ -2,6 +2,11 @@ import SwiftUI
struct SheetGrid: View {
@Bindable var model: SheetModel
+ @State private var editing: Coordinate?
+ @State private var resizing: SheetResizePreview?
+ @State private var savingResize = false
+ @GestureState private var draggingResize = false
+ @FocusState private var focused: Bool
var beginEditing: () -> Void = {}
var body: some View {
ScrollView([.horizontal, .vertical]) {
@@ -9,10 +14,25 @@ struct SheetGrid: View {
Section {
ForEach(0..<model.rows, id: \.self) { row in
HStack(spacing: 0) {
- Text(String(row + 1)).frame(width: 48, height: height(row)).background(.quaternary)
+ ZStack(alignment: .bottom) {
+ Button { selectRow(row) } label: {
+ Text(String(row + 1)).frame(width: 48, height: height(row)).background(.quaternary)
+ }.buttonStyle(.plain).accessibilityLabel(String(format: sheetsMacString("Select row %d"), row + 1))
+ #if os(macOS)
+ resizeHandle(axis: "row", index: row).frame(width: 48, height: 8)
+ #else
+ Color.clear.frame(width: 48, height: 8)
+ .contentShape(.rect).gesture(resizeGesture(axis: "row", index: row))
+ .accessibilityHidden(true)
+ #endif
+ }
ForEach(0..<model.cols, id: \.self) { col in
SheetCell(
- model: model, point: Coordinate(r: row, c: col), beginEditing: beginEditing)
+ model: model, point: Coordinate(r: row, c: col), editing: $editing,
+ select: { select(Coordinate(r: row, c: col)) },
+ beginEditing: { edit(Coordinate(r: row, c: col)) },
+ endEditing: { editing = nil; focused = true },
+ columnWidth: width(col), rowHeight: height(row))
}
}
}
@@ -20,43 +40,183 @@ struct SheetGrid: View {
HStack(spacing: 0) {
Text("").frame(width: 48, height: 32)
ForEach(0..<model.cols, id: \.self) { col in
- Text(Coordinate(r: 0, c: col).ref.dropLast()).frame(width: width(col), height: 32)
+ ZStack(alignment: .trailing) {
+ Button { selectColumn(col) } label: {
+ Text(Coordinate(r: 0, c: col).ref.dropLast())
+ .frame(width: width(col), height: 32)
+ .contentShape(.rect)
+ }.buttonStyle(.plain)
+ .accessibilityLabel(String(format: sheetsMacString("Select column %@"), String(Coordinate(r: 0, c: col).ref.dropLast())))
+ #if os(macOS)
+ resizeHandle(axis: "col", index: col).frame(width: 8, height: 32)
+ .help(sheetsMacString("Double-click to fit column to contents"))
+ #else
+ Color.clear.frame(width: 8, height: 32)
+ #endif
+ }
+ #if os(macOS)
+ .contextMenu { Button(sheetsMacString("Fit column to contents")) { Task { await model.fitColumn(col) } } }
+ #endif
}
}.background(.regularMaterial)
}
}
- }.accessibilityLabel("Spreadsheet grid")
+ }.defaultScrollAnchor(.topLeading)
+ .accessibilityLabel("Spreadsheet grid")
.focusable()
+ .focusEffectDisabled()
+ .focused($focused)
+ .background { SheetSelectionObserver(model: model, editing: $editing) }
+ .onChange(of: focused) { _, value in model.gridFocused = value }
+ .onDisappear { model.gridFocused = false; if !savingResize { resizing = nil } }
+ .onChange(of: draggingResize) { _, active in
+ if !active, !savingResize { resizing = nil }
+ }
.onKeyPress(.leftArrow) { move(0, -1) }
.onKeyPress(.rightArrow) { move(0, 1) }
.onKeyPress(.upArrow) { move(-1, 0) }
.onKeyPress(.downArrow) { move(1, 0) }
.onKeyPress(.delete) {
- guard model.mayWrite else { return .ignored }
+ guard editing == nil, model.mayWrite else { return .ignored }
Task { await model.clear() }
return .handled
}
.onKeyPress(.return) {
- guard model.mayWrite else { return .ignored }
- beginEditing()
+ guard editing == nil, model.mayWrite else { return .ignored }
+ edit(model.selection.start)
return .handled
}
- .onKeyPress(characters: .alphanumerics.union(.punctuationCharacters).union(.symbols)) {
+ .onKeyPress(characters: .alphanumerics.union(.punctuationCharacters).union(.symbols).union(.whitespaces)) {
press in
- guard model.mayWrite, !model.busy, !model.hasUnconfirmedChanges,
+ guard model.mayWrite, !model.busy, !press.characters.contains(where: { $0 == "\t" || $0.isNewline }),
press.modifiers.intersection([.command, .control, .option]).isEmpty
else { return .ignored }
- model.draft = press.characters
- beginEditing()
+ if editing != nil {
+ // Events arriving before the cell's native field gains focus still
+ // belong to this edit. Once focused, NSTextField handles them itself.
+ model.draft += press.characters
+ } else {
+ guard !model.hasUnconfirmedChanges else { return .ignored }
+ model.draft = press.characters
+ edit(model.selection.start)
+ }
return .handled
}
}
+ private func edit(_ point: Coordinate) {
+ guard model.mayWrite, !model.busy, model.failedWrites.isEmpty else { return }
+ #if os(macOS)
+ editing = point
+ #else
+ beginEditing()
+ #endif
+ }
+ private func select(_ point: Coordinate) {
+ guard !model.busy else { return }
+ if model.hasUnconfirmedChanges {
+ Task {
+ await model.saveAll()
+ guard !model.hasUnconfirmedChanges, !model.busy else { return }
+ editing = nil; model.select(point); focused = true
+ }
+ } else {
+ if editing != point { editing = nil }
+ model.select(point)
+ focused = true
+ }
+ }
+ private func selectRow(_ row: Int) {
+ guard !model.hasUnconfirmedChanges, !model.busy else { return }
+ editing = nil
+ model.rangeText = "A\(row + 1):" + Coordinate(r: row, c: model.cols - 1).ref
+ model.selectRange()
+ focused = true
+ }
+ private func selectColumn(_ col: Int) {
+ guard !model.hasUnconfirmedChanges, !model.busy else { return }
+ editing = nil
+ model.rangeText = Coordinate(r: 0, c: col).ref + ":" + Coordinate(r: model.rows - 1, c: col).ref
+ model.selectRange()
+ focused = true
+ }
private func move(_ r: Int, _ c: Int) -> KeyPress.Result {
+ guard editing == nil else { return .ignored }
let p = model.selection.start
model.select(
Coordinate(r: max(0, min(model.rows - 1, p.r + r)), c: max(0, min(model.cols - 1, p.c + c))))
return .handled
}
- private func width(_ col: Int) -> Double { model.sizes.cols?[String(col)] ?? 110 }
- private func height(_ row: Int) -> Double { model.sizes.rows?[String(row)] ?? 32 }
+ private func width(_ col: Int) -> Double {
+ if let resizing, resizing.axis == "col", resizing.index == col { return resizing.size }
+ return model.sizes.cols?[String(col)] ?? 110
+ }
+ private func height(_ row: Int) -> Double {
+ if let resizing, resizing.axis == "row", resizing.index == row { return resizing.size }
+ return model.sizes.rows?[String(row)] ?? 32
+ }
+ #if os(macOS)
+ private func resizeHandle(axis: String, index: Int) -> some View {
+ SheetResizeHandle(vertical: axis == "row",
+ canBegin: { model.mayWrite && !model.busy && !model.hasUnconfirmedChanges && !savingResize },
+ change: { translation in
+ if resizing == nil {
+ resizing = SheetResizePreview(axis: axis, index: index,
+ original: axis == "col" ? width(index) : height(index))
+ }
+ resizing?.update(translation: translation)
+ }, finish: { finishResize(axis: axis, index: index) },
+ cancel: { if !savingResize { resizing = nil } },
+ fit: axis == "col" ? { Task { await model.fitColumn(index) } } : nil)
+
+ }
+ #endif
+ private func finishResize(axis: String, index: Int) {
+ guard let preview = resizing, preview.axis == axis, preview.index == index else { return }
+ savingResize = true
+ Task {
+ await model.resize(axis: axis, size: Int(preview.size), index: index)
+ resizing = nil
+ savingResize = false
+ }
+ }
+ private func resizeGesture(axis: String, index: Int) -> some Gesture {
+ DragGesture(minimumDistance: 2, coordinateSpace: .global)
+ .updating($draggingResize) { _, active, _ in active = true }
+ .onChanged { value in
+ guard model.mayWrite, !model.busy, !model.hasUnconfirmedChanges else { return }
+ if resizing == nil {
+ resizing = SheetResizePreview(axis: axis, index: index,
+ original: axis == "col" ? width(index) : height(index))
+ }
+ guard resizing?.axis == axis, resizing?.index == index else { return }
+ resizing?.update(translation: axis == "col" ? value.translation.width : value.translation.height)
+ }
+ .onEnded { _ in finishResize(axis: axis, index: index) }
+ }
+}
+
+/// Observe selection in a leaf, so moving between cells does not invalidate the
+/// LazyVStack and every row's view construction.
+private struct SheetSelectionObserver: View {
+ let model: SheetModel
+ @Binding var editing: Coordinate?
+ var body: some View {
+ Color.clear.frame(width: 0, height: 0)
+ .onChange(of: model.selection.start) { _, point in
+ if editing != point { editing = nil }
+ }
+ }
+}
+
+struct SheetResizePreview {
+ let axis: String
+ let index: Int
+ let original: Double
+ var size: Double
+ init(axis: String, index: Int, original: Double) {
+ self.axis = axis; self.index = index; self.original = original; self.size = original
+ }
+ mutating func update(translation: Double) {
+ size = min(2000, max(16, (original + translation).rounded()))
+ }
}
diff --git a/native/apple/Sources/SheetsKit/SheetLive.swift b/native/apple/Sources/SheetsKit/SheetLive.swift
index dced629..45de52d 100644
--- a/native/apple/Sources/SheetsKit/SheetLive.swift
+++ b/native/apple/Sources/SheetsKit/SheetLive.swift
@@ -3,37 +3,47 @@ import Foundation
@MainActor extension SheetModel {
func watch() async {
guard page != nil else { return }
- let identity = page?.Sheet.ID
- while !Task.isCancelled && page?.Sheet.ID == identity {
+ let source = grid
+ while !Task.isCancelled && grid == source {
live = "Connecting"
do {
// Subscribe before fetching the snapshot in onOpen; buffered deltas
// then reconcile by version once the initial fetch finishes.
let parser = SheetEventParser()
try await client.stream(
- grid + "/stream",
+ source + "/stream",
onOpen: { [weak self] in
- await self?.refresh()
- await self?.setLive()
+ await self?.streamOpened(source: source)
}
) { [weak self] line in
let event = await parser.receive(line)
guard let event else { return }
- await self?.received(event)
+ await self?.received(event, source: source)
}
- } catch is CancellationError { return } catch { live = "Reconnecting" }
+ } catch is CancellationError { return } catch {
+ guard !Task.isCancelled, grid == source else { return }
+ live = "Reconnecting"
+ }
if live == "Access changed" || live == "Paused" { return }
do { try await Task.sleep(for: .seconds(2)) } catch { return }
}
}
- private func setLive() { live = "Live" }
- private func received(_ event: SheetEvent) async {
+ private func streamOpened(source: String) async {
+ guard !Task.isCancelled, grid == source else { return }
+ await refresh()
+ guard !Task.isCancelled, grid == source else { return }
+ live = "Live"
+ }
+ func received(_ event: SheetEvent, source: String) async {
+ guard !Task.isCancelled, grid == source else { return }
switch event.name {
case "cells":
live = "Live"
- if let response = try? JSONDecoder().decode(GridResponse.self, from: Data(event.data.utf8)) {
+ if let response = try? await wire.decode(Data(event.data.utf8)) {
+ guard !Task.isCancelled, grid == source else { return }
await accept(response)
} else {
+ guard !Task.isCancelled, grid == source else { return }
await refresh()
}
case "resync": await refresh()
diff --git a/native/apple/Sources/SheetsKit/SheetModel.swift b/native/apple/Sources/SheetsKit/SheetModel.swift
index 1cf6c09..2decf12 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] = [:]
@@ -11,6 +14,7 @@ import RastrilloApple
var version: UInt64 = 0
var selection = CellRange(Coordinate(r: 0, c: 0), Coordinate(r: 0, c: 0))
var rangeText = "A1"
+ var gridFocused = false
var draft = ""
var originalDraft = ""
var dirty: Bool { draft != originalDraft }
@@ -28,6 +32,7 @@ import RastrilloApple
var showTable = false
var copied: [[CellWrite]]?
var copiedText = ""
+ let wire = SheetWireWorker()
let client: NativeHTTP
init(client: NativeHTTP) { self.client = client }
var base: String { "/sheets/\(page?.Sheet.ID ?? 0)" }
@@ -36,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 {
@@ -64,7 +76,8 @@ import RastrilloApple
} catch { self.error = error.localizedDescription }
}
func refresh() async {
- guard let sheetID = page?.Sheet.ID else { return }
+ guard page != nil else { return }
+ let source = grid
do {
var updated: [Coordinate: Cell] = [:]
var formats: [String: CellStyle] = [:]
@@ -80,8 +93,8 @@ import RastrilloApple
guard status == 200 else {
throw NativeClientError.message("The sheet could not be refreshed.")
}
- let response = try JSONDecoder().decode(GridResponse.self, from: data)
- guard page?.Sheet.ID == sheetID else { return }
+ let response = try await wire.decode(data)
+ guard !Task.isCancelled, grid == source else { return }
fetchedVersion = min(fetchedVersion, response.version)
for cell in response.cells ?? [] { updated[cell.coordinate] = cell }
formats.merge(response.formats ?? [:]) { _, new in new }
@@ -105,9 +118,17 @@ import RastrilloApple
await refresh()
return
}
+ if let changed = response.cells, !changed.isEmpty {
+ var nextCells = cells
+ for cell in changed { nextCells[cell.coordinate] = cell }
+ cells = nextCells
+ }
+ if let formats = response.formats, !formats.isEmpty {
+ var nextStyles = styles
+ nextStyles.merge(formats) { _, new in new }
+ styles = nextStyles
+ }
version = response.version
- for cell in response.cells ?? [] { cells[cell.coordinate] = cell }
- styles.merge(response.formats ?? [:]) { _, new in new }
if let sizes = response.sizes { self.sizes = sizes }
refreshDraftIfClean()
}
@@ -177,16 +198,20 @@ import RastrilloApple
await accept(value)
} catch { self.error = error.localizedDescription }
}
- func resize(axis: String, size: Int) async {
+ func resize(axis: String, size: Int, index: Int? = nil) async {
guard mayWrite && !busy && !hasUnconfirmedChanges else { return }
busy = true
defer { busy = false }
do {
- let response: GridResponse = try await client.request(
+ let (bytes, status) = try await client.response(
grid + "/size", method: "POST",
body: JSONSerialization.data(withJSONObject: [
- axis: axis == "col" ? selection.start.c : selection.start.r, "size": size,
- ]))
+ axis: index ?? (axis == "col" ? selection.start.c : selection.start.r), "size": size,
+ ]), maxBytes: 64 << 20)
+ guard (200..<300).contains(status) else {
+ throw NativeClientError.message(sheetsMacString("The server did not confirm the resize."))
+ }
+ let response = try await wire.decode(bytes)
await accept(response)
} catch { self.error = error.localizedDescription }
}
@@ -228,10 +253,10 @@ import RastrilloApple
return false
}
}
- func create() async {
+ @discardableResult func create(openCreated: Bool = true) async -> SheetSummary? {
guard !hasUnconfirmedChanges && !busy else {
error = "Save or discard the current edit first."
- return
+ return nil
}
busy = true
defer { busy = false }
@@ -239,8 +264,9 @@ import RastrilloApple
let sheet: SheetSummary = try await form("/sheets", [:])
await loadList()
busy = false
- await open(sheet)
- } catch { self.error = error.localizedDescription }
+ if openCreated { await open(sheet) }
+ return sheet
+ } catch { self.error = error.localizedDescription; return nil }
}
func loadHistory() async {
let sheetID = page?.Sheet.ID
diff --git a/native/apple/Sources/SheetsKit/SheetPanels.swift b/native/apple/Sources/SheetsKit/SheetPanels.swift
index 5d7feb7..5c25b2b 100644
--- a/native/apple/Sources/SheetsKit/SheetPanels.swift
+++ b/native/apple/Sources/SheetsKit/SheetPanels.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct SheetPanels: View {
@Bindable var model: SheetModel
let panel: String
+ var openImported: ((UInt64, String) -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var width = 110
@@ -79,7 +80,7 @@ struct SheetPanels: View {
.font(.caption)
case "Comments": SheetComments(model: model)
case "Share": SheetSharing(model: model)
- case "Import", "Export": SheetFiles(model: model, importing: panel == "Import")
+ case "Import", "Export": SheetFiles(model: model, importing: panel == "Import", openImported: openImported)
default: EmptyView()
}
}.formStyle(.grouped).navigationTitle(panel)
diff --git a/native/apple/Sources/SheetsKit/SheetResizeHandle.swift b/native/apple/Sources/SheetsKit/SheetResizeHandle.swift
new file mode 100644
index 0000000..c1ef577
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetResizeHandle.swift
@@ -0,0 +1,65 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+struct SheetResizeHandle: NSViewRepresentable {
+ let vertical: Bool
+ let canBegin: () -> Bool
+ let change: (Double) -> Void
+ let finish: () -> Void
+ let cancel: () -> Void
+ var fit: (() -> Void)? = nil
+ func makeNSView(context: Context) -> Handle { Handle() }
+ func updateNSView(_ view: Handle, context: Context) { view.actions = self }
+ final class Handle: NSControl {
+ override func draw(_ dirtyRect: NSRect) {
+ NSColor.secondaryLabelColor.withAlphaComponent(0.15).setFill()
+ // Keep the 8-point mouse target, but paint only the grid boundary.
+ let pixel = 1 / (window?.backingScaleFactor ?? 2)
+ let rule = actions?.vertical == true
+ ? NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width, height: pixel)
+ : NSRect(x: bounds.maxX - pixel, y: bounds.minY, width: pixel, height: bounds.height)
+ rule.fill()
+ }
+ var actions: SheetResizeHandle?
+ private var start: NSPoint?
+ private var moved = false
+ private weak var previousResponder: NSResponder?
+ override var acceptsFirstResponder: Bool { true }
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
+ override func resetCursorRects() {
+ addCursorRect(bounds, cursor: actions?.vertical == true ? .resizeUpDown : .resizeLeftRight)
+ }
+ override func mouseDown(with event: NSEvent) {
+ guard actions?.canBegin() == true else { return }
+ if event.clickCount == 2, let fit = actions?.fit { fit(); return }
+ previousResponder = window?.firstResponder
+ window?.makeFirstResponder(self)
+ start = event.locationInWindow; moved = false
+ }
+ override func mouseDragged(with event: NSEvent) {
+ guard let start else { return }
+ let amount = actions?.vertical == true ? start.y - event.locationInWindow.y : event.locationInWindow.x - start.x
+ guard moved || abs(amount) >= 2 else { return }
+ moved = true; actions?.change(amount)
+ }
+ override func mouseUp(with event: NSEvent) {
+ guard start != nil else { return }
+ if moved { actions?.finish() }
+ reset()
+ }
+ override func keyDown(with event: NSEvent) {
+ if event.keyCode == 53 { actions?.cancel(); reset() } else { super.keyDown(with: event) }
+ }
+ override func viewWillMove(toWindow newWindow: NSWindow?) {
+ if newWindow == nil, start != nil { actions?.cancel(); reset() }
+ super.viewWillMove(toWindow: newWindow)
+ }
+ private func reset() {
+ start = nil; moved = false
+ if window?.firstResponder === self { window?.makeFirstResponder(previousResponder) }
+ previousResponder = nil
+ }
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetTable.swift b/native/apple/Sources/SheetsKit/SheetTable.swift
index 3d6d4b1..0af8392 100644
--- a/native/apple/Sources/SheetsKit/SheetTable.swift
+++ b/native/apple/Sources/SheetsKit/SheetTable.swift
@@ -22,7 +22,7 @@ struct SheetTable: View {
HStack(spacing: 0) {
Text(String(row.Number)).frame(width: 48)
ForEach(row.Cells, id: \.C) { cell in
- SheetCell(model: model, point: Coordinate(r: cell.R, c: cell.C))
+ SheetCell(model: model, point: Coordinate(r: cell.R, c: cell.C), editing: .constant(nil), select: { model.select(Coordinate(r: cell.R, c: cell.C)) }, endEditing: {})
}
}
}
diff --git a/native/apple/Sources/SheetsKit/SheetThumbnail.swift b/native/apple/Sources/SheetsKit/SheetThumbnail.swift
new file mode 100644
index 0000000..d4c6c9c
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetThumbnail.swift
@@ -0,0 +1,49 @@
+import SwiftUI
+
+struct SheetPreview: Decodable, Hashable, Sendable {
+ let Rows: Int
+ let Cols: Int
+ let Empty: Bool
+ let Cells: [SheetPreviewCell]?
+}
+struct SheetPreviewCell: Decodable, Hashable, Sendable {
+ let R: Int
+ let C: Int
+ let Fill: String
+ let Bold: Bool
+}
+
+/// Renders shape metadata only: no cell text, web view, or external image fetch.
+struct SheetThumbnail: View {
+ private let preview: SheetPreview?
+ var width: CGFloat = 52
+ var height: CGFloat = 68
+ init(_ preview: SheetPreview?, width: CGFloat = 52, height: CGFloat = 68) { self.preview = preview; self.width = width; self.height = height }
+
+ var body: some View {
+ Canvas { context, size in
+ let border = Path(roundedRect: CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5), cornerRadius: 3)
+ context.fill(border, with: .color(.primary.opacity(0.025)))
+ context.stroke(border, with: .color(.secondary.opacity(0.3)), lineWidth: 1)
+ guard let preview, !preview.Empty, (1...12).contains(preview.Rows),
+ (1...8).contains(preview.Cols) else { return }
+ let width = (size.width - 8) / Double(preview.Cols)
+ let height = (size.height - 8) / Double(preview.Rows)
+ for cell in (preview.Cells ?? []).prefix(96) {
+ guard (0..<preview.Rows).contains(cell.R), (0..<preview.Cols).contains(cell.C) else { continue }
+ let rect = CGRect(x: 4 + Double(cell.C) * width, y: 4 + Double(cell.R) * height, width: width, height: height)
+ if !cell.Fill.isEmpty {
+ let fill = cell.Fill
+ let isHex = fill.count == 7 && fill.hasPrefix("#") && UInt64(fill.dropFirst(), radix: 16) != nil
+ context.fill(Path(rect), with: .color(isHex ? Color(hex: fill) : .accentColor.opacity(0.15)))
+ }
+ let mark = CGRect(x: rect.minX + width * 0.15, y: rect.minY + height * 0.35,
+ width: width * 0.7, height: height * 0.3)
+ context.fill(Path(roundedRect: mark, cornerRadius: 0.5),
+ with: .color(.secondary.opacity(cell.Bold ? 0.8 : 0.4)))
+ }
+ }
+ .frame(width: width, height: height)
+ .accessibilityHidden(true)
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetWire.swift b/native/apple/Sources/SheetsKit/SheetWire.swift
index fc56d43..a7b6c3b 100644
--- a/native/apple/Sources/SheetsKit/SheetWire.swift
+++ b/native/apple/Sources/SheetsKit/SheetWire.swift
@@ -5,6 +5,7 @@ struct SheetSummary: Decodable, Identifiable, Hashable, Sendable {
let Title: String
let TabID: UInt64
let TabName: String
+ var Preview: SheetPreview? = nil
var id: UInt64 { self.ID }
}
struct SheetList: Decodable { let Sheets: [SheetSummary]? }
diff --git a/native/apple/Sources/SheetsKit/SheetWireWorker.swift b/native/apple/Sources/SheetsKit/SheetWireWorker.swift
new file mode 100644
index 0000000..ca7a6fc
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetWireWorker.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+// Network awaits alone do not move JSON processing off the main actor.
+actor SheetWireWorker {
+ func decode(_ data: Data) throws -> GridResponse {
+ try JSONDecoder().decode(GridResponse.self, from: data)
+ }
+
+ func encode(_ writes: [CellWrite], startingAt offset: Int) throws -> (data: Data, count: Int) {
+ struct Batch: Encodable { let cells: [CellWrite] }
+ var count = min(500, writes.count - offset)
+ var data = try JSONEncoder().encode(Batch(cells: Array(writes[offset..<offset + count])))
+ while data.count > 1 << 20 && count > 1 {
+ count /= 2
+ data = try JSONEncoder().encode(Batch(cells: Array(writes[offset..<offset + count])))
+ }
+ return (data, count)
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetWrites.swift b/native/apple/Sources/SheetsKit/SheetWrites.swift
index 1f6384f..375e005 100644
--- a/native/apple/Sources/SheetsKit/SheetWrites.swift
+++ b/native/apple/Sources/SheetsKit/SheetWrites.swift
@@ -17,20 +17,16 @@ import RastrilloApple
defer { busy = false }
var offset = 0
do {
- struct Batch: Encodable { let cells: [CellWrite] }
while offset < writes.count {
- var count = min(500, writes.count - offset)
- var data = try JSONEncoder().encode(Batch(cells: Array(writes[offset..<offset + count])))
- while data.count > 1 << 20 && count > 1 {
- count /= 2
- data = try JSONEncoder().encode(Batch(cells: Array(writes[offset..<offset + count])))
- }
+ let batch = try await wire.encode(writes, startingAt: offset)
+ let count = batch.count
+ let data = batch.data
let (bytes, status) = try await client.response(
grid + "/cells/batch", method: "POST", body: data, maxBytes: 64 << 20)
guard (200..<300).contains(status) else {
throw NativeClientError.message("The server did not confirm the edit.")
}
- let response = try JSONDecoder().decode(GridResponse.self, from: bytes)
+ let response = try await wire.decode(bytes)
await accept(response)
offset += count
}
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/SheetsDocumentMenu.swift b/native/apple/Sources/SheetsKit/SheetsDocumentMenu.swift
new file mode 100644
index 0000000..894ca7a
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetsDocumentMenu.swift
@@ -0,0 +1,24 @@
+#if os(macOS)
+import SwiftUI
+
+public struct SheetsDocumentMenu: View {
+ @FocusedValue(\.sheetEditor) private var editor
+ public init() {}
+ private var available: Bool { editor?.model.page != nil }
+ private var writable: Bool {
+ guard let model = editor?.model else { return false }
+ return model.mayWrite && !model.busy && !model.hasUnconfirmedChanges
+ }
+ public var body: some View {
+ Group {
+ Button(sheetsMacString("Save")) { if let model = editor?.model { Task { await model.saveAll() } } }
+ .keyboardShortcut("s").disabled(!available)
+ Button(sheetsMacString("Rename…")) { editor?.panel.wrappedValue = "Rename" }.disabled(!writable)
+ Button(sheetsMacString("Export workbook…")) { editor?.panel.wrappedValue = "Export" }.disabled(!available)
+ Button(sheetsMacString("Version history…")) { editor?.panel.wrappedValue = "History" }.disabled(!available)
+ Button(sheetsMacString("Share…")) { editor?.panel.wrappedValue = "Share" }.disabled(editor?.model.page?.MayShare != true)
+
+ }
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetsEditorCommands.swift b/native/apple/Sources/SheetsKit/SheetsEditorCommands.swift
new file mode 100644
index 0000000..9151495
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetsEditorCommands.swift
@@ -0,0 +1,61 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+public struct SheetsEditorCommands: Commands {
+ @FocusedValue(\.sheetEditor) private var editor
+ public init() {}
+ private var available: Bool { editor?.model.page != nil }
+ private var writable: Bool {
+ guard let model = editor?.model else { return false }
+ return model.mayWrite && !model.busy && !model.hasUnconfirmedChanges
+ }
+ private func route(_ selector: Selector, grid: (SheetModel) -> Void) {
+ if NSApp.keyWindow?.firstResponder is NSTextView {
+ NSApp.sendAction(selector, to: nil, from: nil)
+ } else if let model = editor?.model, model.gridFocused { grid(model) }
+ else { NSApp.sendAction(selector, to: nil, from: nil) }
+ }
+ public var body: some Commands {
+
+ CommandGroup(replacing: .undoRedo) {
+ Button(sheetsMacString("Undo")) { route(Selector(("undo:"))) { model in Task { await model.command("/undo") } } }.keyboardShortcut("z")
+ Button(sheetsMacString("Redo")) { NSApp.sendAction(Selector(("redo:")), to: nil, from: nil) }.keyboardShortcut("z", modifiers: [.command, .shift])
+ }
+ CommandGroup(replacing: .pasteboard) {
+ Button(sheetsMacString("Cut")) { route(#selector(NSText.cut(_:))) { model in
+ guard model.mayWrite, !model.busy, !model.hasUnconfirmedChanges else { return }
+ model.copy(); Task { await model.clear() }
+ } }.keyboardShortcut("x")
+ Button(sheetsMacString("Copy")) { route(#selector(NSText.copy(_:))) { $0.copy() } }.keyboardShortcut("c")
+ Button(sheetsMacString("Paste")) { route(#selector(NSText.paste(_:))) { model in Task { await model.paste() } } }.keyboardShortcut("v")
+ Button(sheetsMacString("Select all")) { route(#selector(NSText.selectAll(_:))) { model in
+ model.rangeText = "A1:" + Coordinate(r: model.rows - 1, c: model.cols - 1).ref
+ model.selectRange()
+ } }.keyboardShortcut("a")
+ }
+ CommandGroup(after: .pasteboard) {
+ Button(sheetsMacString("Clear contents")) { if let model = editor?.model { Task { await model.clear() } } }.disabled(!writable)
+ Button(sheetsMacString("Fill down")) { if let model = editor?.model { Task { await model.fill(down: true) } } }.keyboardShortcut("d").disabled(!writable)
+ Button(sheetsMacString("Fill right")) { if let model = editor?.model { Task { await model.fill(down: false) } } }.disabled(!writable)
+ }
+ CommandMenu(sheetsMacString("Format")) {
+ if let model = editor?.model {
+ SheetFormatMenu(model: model)
+ Button(sheetsMacString("Row and column sizes…")) { editor?.panel.wrappedValue = "Sizes" }.disabled(!writable)
+ Button(sheetsMacString("Fit column to contents")) { Task { await model.fitColumn(model.selection.start.c) } }.disabled(!writable)
+ }
+ }
+ CommandMenu(sheetsMacString("Insert")) {
+ ForEach(["SUM", "AVERAGE", "COUNT", "MIN", "MAX"], id: \.self) { name in
+ Button(name) { if let model = editor?.model { Task { await model.insertFunction(name) } } }.disabled(!writable)
+ }
+ }
+ CommandMenu(sheetsMacString("Sheet")) {
+ Button(sheetsMacString("Sort and filter…")) { editor?.panel.wrappedValue = "Data" }.disabled(!available)
+ Button(sheetsMacString("Comments…")) { editor?.panel.wrappedValue = "Comments" }.disabled(!available)
+ Button(sheetsMacString("Refresh")) { if let model = editor?.model { Task { await model.refresh() } } }.disabled(!available)
+ }
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetsMacStrings.swift b/native/apple/Sources/SheetsKit/SheetsMacStrings.swift
new file mode 100644
index 0000000..0bbd405
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetsMacStrings.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+public func sheetsMacString(_ key: String) -> String {
+ NSLocalizedString(key, tableName: "Mac", bundle: SheetsResources.bundle, comment: "")
+}
+
+// SwiftPM's generated lookup checks the executable bundle root and then an
+// absolute build-machine path. Installed macOS apps put resources in Contents/Resources.
+enum SheetsResources {
+ static let bundle: Bundle = {
+ if let url = Bundle.main.url(forResource: "SheetsNative_SheetsKit", withExtension: "bundle"),
+ let bundle = Bundle(url: url) { return bundle }
+ return .module
+ }()
+}
diff --git a/native/apple/Sources/SheetsKit/SheetsWorkspace.swift b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift
index dabc154..72dec82 100644
--- a/native/apple/Sources/SheetsKit/SheetsWorkspace.swift
+++ b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift
@@ -9,47 +9,33 @@ import SwiftUI
var id: String { name }
}
@State private var panel: Panel?
- @State private var title = ""
+ @State private var query = ""
@State private var pendingSheet: SheetSummary?
@State private var askDiscard = false
@FocusState private var formulaFocused: Bool
@Environment(\.scenePhase) private var scenePhase
@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
- public init(client: NativeHTTP, signOut: @escaping () -> 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), 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
}
public var body: some View {
- NavigationSplitView(preferredCompactColumn: $column) {
- List {
- ForEach(model.sheets) { sheet in
- Button(sheet.Title) {
- if model.hasUnconfirmedChanges {
- pendingSheet = sheet
- askDiscard = true
- } else {
- Task { await model.open(sheet) }
- }
- }.disabled(model.busy)
- }
- Button("New sheet", systemImage: "plus") { Task { await model.create() } }.disabled(
- model.hasUnconfirmedChanges || model.busy)
- Button("Import workbook", systemImage: "square.and.arrow.down") { show("Import") }.disabled(
- model.hasUnconfirmedChanges || model.busy)
- Picker("Appearance", selection: $appearance) {
- Text("System").tag("auto")
- Text("Light").tag("light")
- Text("Dark").tag("dark")
- }
- Button("Sign out") {
- if !model.hasUnconfirmedChanges && !model.busy {
- signOut()
- } else {
- model.error = "Save or discard your cell edit before signing out."
- }
- }
- }.navigationTitle("Sheets")
+ Group {
+ #if os(macOS)
+ if documentID != nil { editor } else { home }
+ #else
+ NavigationSplitView(preferredCompactColumn: $column) {
+ library
} detail: {
if model.page != nil {
editor
@@ -59,7 +45,22 @@ import SwiftUI
description: Text("Create a spreadsheet or open one from the list."))
}
}
- .task { await model.loadList() }
+ #endif
+ }
+ .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() } }
+ }
+ .task {
+ if let documentID {
+ await model.open(SheetSummary(ID: documentID, Title: "", TabID: 0, TabName: ""))
+ } else { await model.loadList() }
+ }
.onChange(of: model.page?.Sheet.ID) { _, value in if value != nil { column = .detail } }
.onChange(of: scenePhase) { _, value in
if value != .active { model.clearCopyProvenance() }
@@ -71,7 +72,7 @@ import SwiftUI
#if os(macOS)
.background(SheetCloseGuard(model: model).frame(width: 0, height: 0))
#endif
- .sheet(item: $panel) { item in SheetPanels(model: model, panel: item.name) }
+ .sheet(item: $panel) { item in SheetPanels(model: model, panel: item.name, openImported: openDocument) }
.confirmationDialog("Keep your cell edit?", isPresented: $askDiscard, titleVisibility: .visible)
{
Button("Save and continue") {
@@ -87,6 +88,93 @@ import SwiftUI
Button("Cancel", role: .cancel) { pendingSheet = nil }
}
}
+ private func createSheet() async {
+ #if os(macOS)
+ if let sheet = await model.create(openCreated: false) { openDocument?(sheet.ID, sheet.Title) }
+ #else
+ await model.create()
+ #endif
+ }
+ private var filteredSheets: [SheetSummary] {
+ query.isEmpty ? model.sheets : model.sheets.filter { $0.Title.localizedStandardContains(query) }
+ }
+ private var library: some View {
+ List {
+ if !model.error.isEmpty {
+ Label(model.error, systemImage: "exclamationmark.circle").foregroundStyle(.red)
+ }
+ ForEach(filteredSheets) { sheet in
+ Button {
+ if model.hasUnconfirmedChanges {
+ pendingSheet = sheet
+ askDiscard = true
+ } else { Task { await model.open(sheet) } }
+ } label: {
+ HStack(spacing: 16) {
+ SheetThumbnail(sheet.Preview)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(verbatim: sheet.Title).font(.headline).foregroundStyle(.primary)
+ Text(verbatim: sheet.TabName).font(.subheadline).foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 0)
+ Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary)
+ }.padding(.vertical, 6)
+ }.disabled(model.busy).accessibilityElement(children: .combine)
+ .accessibilityLabel(sheet.Title).accessibilityValue(sheet.TabName)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ 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 {
+ ContentUnavailableView.search(text: query)
+ }
+ }
+ .navigationTitle("Sheets")
+ .searchable(text: $query, prompt: sheetsMacString("Search spreadsheets"))
+ .refreshable { await model.loadList() }
+ .toolbar {
+ if let accounts {
+ ToolbarItem(placement: .navigation) {
+ Button(sheetsMacString("Accounts"), systemImage: "chevron.backward", action: accounts)
+ .disabled(model.hasUnconfirmedChanges || model.busy)
+ }
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Menu {
+ Button("Import", systemImage: "square.and.arrow.down") { show("Import") }
+ .disabled(model.hasUnconfirmedChanges || model.busy)
+ Picker("Appearance", selection: $appearance) {
+ Text("System").tag("auto")
+ Text("Light").tag("light")
+ Text("Dark").tag("dark")
+ }
+ Button(sheetsMacString("Sign out"), systemImage: "rectangle.portrait.and.arrow.right", role: .destructive,
+ action: signOut).disabled(model.hasUnconfirmedChanges || model.busy)
+ } label: {
+ VStack(spacing: 3) {
+ Circle().frame(width: 3, height: 3)
+ Circle().frame(width: 3, height: 3)
+ Circle().frame(width: 3, height: 3)
+ }.frame(width: 20, height: 20)
+ }.accessibilityLabel("More")
+ }
+ #if os(iOS)
+ DefaultToolbarItem(kind: .search, placement: .bottomBar)
+ ToolbarSpacer(.flexible, placement: .bottomBar)
+ ToolbarItem(placement: .bottomBar) { newSheet }
+ #else
+ ToolbarItem { newSheet }
+ #endif
+ }
+ }
+ private var newSheet: some View {
+ Button(sheetsMacString("New spreadsheet"), systemImage: "plus") { Task { await createSheet() } }
+ .disabled(model.hasUnconfirmedChanges || model.busy)
+ }
private func show(_ name: String) {
panel = Panel(name: name)
}
@@ -102,18 +190,18 @@ import SwiftUI
!model.mayWrite
)
.keyboardShortcut("z")
- Button("Copy") { model.copy() }.keyboardShortcut("c")
- Button("Cut") {
+ Button(sheetsMacString("Copy")) { model.copy() }.keyboardShortcut("c")
+ Button(sheetsMacString("Cut")) {
model.copy()
Task { await model.clear() }
}.keyboardShortcut("x").disabled(!model.mayWrite)
- Button("Paste") { Task { await model.paste() } }.keyboardShortcut("v").disabled(
+ Button(sheetsMacString("Paste")) { Task { await model.paste() } }.keyboardShortcut("v").disabled(
!model.mayWrite)
- Button("Clear contents") { Task { await model.clear() } }.disabled(!model.mayWrite)
- Button("Fill down") { Task { await model.fill(down: true) } }.keyboardShortcut("d").disabled(
+ Button(sheetsMacString("Clear contents")) { Task { await model.clear() } }.disabled(!model.mayWrite)
+ Button(sheetsMacString("Fill down")) { Task { await model.fill(down: true) } }.keyboardShortcut("d").disabled(
!model.mayWrite)
- Button("Fill right") { Task { await model.fill(down: false) } }.disabled(!model.mayWrite)
- Button("Select all") {
+ Button(sheetsMacString("Fill right")) { Task { await model.fill(down: false) } }.disabled(!model.mayWrite)
+ Button(sheetsMacString("Select all")) {
model.rangeText = "A1:" + Coordinate(r: model.rows - 1, c: model.cols - 1).ref
model.selectRange()
}.keyboardShortcut("a")
@@ -129,41 +217,18 @@ import SwiftUI
Button("Sort and filter") { show("Data") }.disabled(
model.hasUnconfirmedChanges || model.busy)
Button("Comments") { show("Comments") }
- Button("Refresh", systemImage: "arrow.clockwise") { Task { await model.refresh() } }
+ Button(sheetsMacString("Refresh"), systemImage: "arrow.clockwise") { Task { await model.refresh() } }
}
private var editor: some View {
VStack(spacing: 0) {
+ #if os(iOS)
ViewThatFits(in: .horizontal) {
HStack { editorCommands }.fixedSize(horizontal: true, vertical: false)
Menu("Sheet actions", systemImage: "ellipsis.circle") { editorCommands }
.frame(maxWidth: .infinity, alignment: .leading)
}.padding(8)
- HStack {
- TextField("Cell or range", text: $model.rangeText).frame(width: 110).onSubmit {
- model.selectRange()
- }.accessibilityLabel("Cell or range, for example A1:B5")
- Text("fx").foregroundStyle(.secondary)
- TextField("Cell contents or formula", text: $model.draft).focused($formulaFocused).disabled(
- !model.mayWrite || model.busy || !model.failedWrites.isEmpty
- )
- .autocorrectionDisabled()
- #if os(iOS)
- .textInputAutocapitalization(.never)
- #endif
- .onSubmit { Task { await model.commit() } }
- .onKeyPress(.escape) {
- model.discard()
- return .handled
- }
- .onKeyPress(.tab) {
- Task { await model.commit(moveRight: true) }
- return .handled
- }
- if model.dirty {
- Button("Save") { Task { await model.commit() } }.disabled(model.busy)
- Button("Discard") { model.discard() }.disabled(model.busy)
- }
- }.textFieldStyle(.roundedBorder).padding(8)
+ #endif
+ SheetFormulaBar(model: model, formulaFocused: $formulaFocused)
if !model.failedWrites.isEmpty {
HStack {
Button("Retry pending edits") { Task { await model.retryWrites() } }.disabled(model.busy)
@@ -182,13 +247,7 @@ import SwiftUI
} else {
SheetGrid(model: model, beginEditing: { formulaFocused = true })
}
- HStack {
- Text(model.page?.Sheet.TabName ?? "Sheet")
- Spacer()
- if model.busy { ProgressView().controlSize(.small) }
- Text(model.dirty ? "Unsaved cell edit" : model.notice)
- Text(model.live).foregroundStyle(.secondary)
- }.font(.caption).padding(8)
+ SheetStatusBar(model: model)
if model.page?.Truncated == true {
Text("This grid shows up to 500 rows and 200 columns. Export includes the complete sheet.")
.font(.caption).padding(4)
@@ -200,8 +259,51 @@ import SwiftUI
).font(.caption).padding(4)
}
}.navigationTitle(model.page?.Sheet.Title ?? "Sheets")
+ #if os(macOS)
+ .focusedSceneValue(\.sheetEditor, SheetEditorContext(model: model, panel: Binding(get: { panel?.name }, set: { panel = $0.map { Panel(name: $0) } })))
+ #endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
+ #if os(macOS)
+ private var home: some View {
+ NavigationSplitView {
+ List { Label(sheetsMacString("Recent"), systemImage: "clock").listRowBackground(Color.accentColor.opacity(0.12)) }
+ .navigationSplitViewColumnWidth(min: 160, ideal: 190, max: 240)
+ } detail: {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+ Text(sheetsMacString("Recent spreadsheets")).font(.largeTitle.bold())
+ if !model.error.isEmpty { Text(model.error).foregroundStyle(.red) }
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: 260))], spacing: 24) {
+ ForEach(filteredSheets) { sheet in
+ Button { openDocument?(sheet.ID, sheet.Title) } label: {
+ VStack(alignment: .leading, spacing: 10) {
+ SheetThumbnail(sheet.Preview, width: 200, height: 150)
+ .background(.background).clipShape(.rect(cornerRadius: 5))
+ Text(sheet.Title).font(.headline).lineLimit(2)
+ Text(sheet.TabName).font(.caption).foregroundStyle(.secondary)
+ }.frame(maxWidth: .infinity, alignment: .leading).padding(12)
+ }.buttonStyle(.plain)
+ }
+ }
+ 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 {
+ newSheet
+ Button(sheetsMacString("Import workbook")) { show("Import") }
+ Button(sheetsMacString("Refresh"), systemImage: "arrow.clockwise") { Task { await model.loadList() } }
+ }
+ }
+ }
+ #endif
+
}
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/apple/Tests/SheetsKitTests/ResizeFixtureServer.swift b/native/apple/Tests/SheetsKitTests/ResizeFixtureServer.swift
new file mode 100644
index 0000000..2bbccd3
--- /dev/null
+++ b/native/apple/Tests/SheetsKitTests/ResizeFixtureServer.swift
@@ -0,0 +1,58 @@
+#if os(macOS)
+import Foundation
+import Network
+
+final class ResizeFixtureServer: @unchecked Sendable {
+ private let listener: NWListener
+ private let queue = DispatchQueue(label: "sheets.resize.fixture")
+ private let lock = NSLock()
+ private var received: [[String: Int]] = []
+ var requests: [[String: Int]] { lock.withLock { received } }
+ init() throws { listener = try NWListener(using: .tcp, on: .any) }
+ func start() async throws -> URL {
+ listener.newConnectionHandler = { [weak self] connection in
+ guard let self else { return }
+ connection.start(queue: self.queue)
+ self.read(connection, buffer: Data())
+ }
+ return try await withCheckedThrowingContinuation { continuation in
+ listener.stateUpdateHandler = { [weak self] state in
+ guard let self else { return }
+ switch state {
+ case .ready:
+ self.listener.stateUpdateHandler = nil
+ continuation.resume(returning: URL(string: "http://127.0.0.1:\(self.listener.port!.rawValue)")!)
+ case .failed(let error):
+ self.listener.stateUpdateHandler = nil
+ continuation.resume(throwing: error)
+ default: break
+ }
+ }
+ listener.start(queue: queue)
+ }
+ }
+ func stop() { listener.cancel() }
+ private func read(_ connection: NWConnection, buffer: Data) {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, complete, error in
+ guard let self, let data, error == nil else { connection.cancel(); return }
+ var bytes = buffer; bytes.append(data)
+ guard let separator = bytes.range(of: Data("\r\n\r\n".utf8)),
+ let headers = String(data: bytes[..<separator.lowerBound], encoding: .utf8),
+ let lengthLine = headers.components(separatedBy: "\r\n").first(where: { $0.lowercased().hasPrefix("content-length:") }),
+ let length = Int(lengthLine.split(separator: ":", maxSplits: 1)[1].trimmingCharacters(in: .whitespaces)),
+ bytes.count - separator.upperBound >= length else {
+ if !complete { self.read(connection, buffer: bytes) } else { connection.cancel() }
+ return
+ }
+ let body = bytes.subdata(in: separator.upperBound..<(separator.upperBound + length))
+ guard let request = try? JSONDecoder().decode([String: Int].self, from: body), let size = request["size"] else { connection.cancel(); return }
+ self.lock.withLock { self.received.append(request) }
+ let axis = request["col"] == nil ? "rows" : "cols"
+ let index = request["col"] ?? request["row"] ?? 0
+ let response = "{\"version\":0,\"cells\":[],\"sizes\":{\"\(axis)\":{\"\(index)\":\(size)}}}"
+ let message = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: \(response.utf8.count)\r\nConnection: close\r\n\r\n\(response)"
+ connection.send(content: Data(message.utf8), completion: .contentProcessed { _ in connection.cancel() })
+ }
+ }
+}
+#endif
diff --git a/native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift b/native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift
new file mode 100644
index 0000000..8ff1ddd
--- /dev/null
+++ b/native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift
@@ -0,0 +1,159 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+import XCTest
+import RastrilloApple
+@testable import SheetsKit
+
+final class SheetNativeInteractionTests: XCTestCase {
+ @MainActor func testTypingEntersCellAndFailedCommitKeepsDraft() async throws {
+ let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://127.0.0.1:1")!))
+ model.page = SheetPage(Sheet: SheetSummary(ID: 1, Title: "Fixture", TabID: 1, TabName: "Sheet 1"), Rows: 4, Cols: 3, Truncated: false, MayWrite: true, MayComment: true, MayShare: false, FillSwatches: [], InkSwatches: [], NumberFormats: [], Warned: nil)
+ let host = NSHostingView(rootView: SheetGrid(model: model).background(Color.white).preferredColorScheme(.light))
+ let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), styleMask: [.titled], backing: .buffered, defer: false)
+ window.contentView = host
+ NSApp.setActivationPolicy(.regular)
+ window.makeKeyAndOrderFront(nil)
+ host.layoutSubtreeIfNeeded()
+ window.displayIfNeeded()
+ NSApp.activate()
+ defer { window.orderOut(nil) }
+ try await Task.sleep(for: .milliseconds(300))
+ for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
+ let event = NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 350), modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)!
+ window.sendEvent(event)
+ }
+ try await Task.sleep(for: .milliseconds(100))
+ key("h", code: 4, window: window)
+ try await Task.sleep(for: .milliseconds(150))
+ XCTAssertEqual(model.draft, "h")
+ XCTAssertTrue(window.firstResponder is NSTextView, "Typing must transfer focus into a native cell text field")
+ key("e", code: 14, window: window)
+ try await Task.sleep(for: .milliseconds(100))
+ XCTAssertEqual(model.draft, "he", "The initial character must not be selected and overwritten")
+ if ProcessInfo.processInfo.environment["OFICINA_CAPTURE_FIXTURES"] == "1",
+ let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds) {
+ host.cacheDisplay(in: host.bounds, to: bitmap)
+ try bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: "/tmp/oficina-sheets-grid-fixture.png"))
+ }
+ key("\t", code: 48, window: window)
+ try await Task.sleep(for: .milliseconds(500))
+ XCTAssertEqual(model.selection.start, Coordinate(r: 0, c: 0))
+ XCTAssertEqual(model.draft, "he")
+ XCTAssertTrue(model.hasUnconfirmedChanges)
+ XCTAssertFalse(model.failedWrites.isEmpty)
+ XCTAssertTrue(window.firstResponder is NSTextView)
+ key("\u{1b}", code: 53, window: window)
+ try await Task.sleep(for: .milliseconds(100))
+ XCTAssertFalse(model.hasUnconfirmedChanges)
+ XCTAssertEqual(model.selection.start, Coordinate(r: 0, c: 0))
+ // Column B header then row 2 header select complete ranges.
+ click(NSPoint(x: 200, y: 375), window: window)
+ try await Task.sleep(for: .milliseconds(100))
+ XCTAssertEqual(model.selection.start, Coordinate(r: 0, c: 1))
+ XCTAssertEqual(model.selection.end, Coordinate(r: 3, c: 1))
+ click(NSPoint(x: 24, y: 320), window: window)
+ try await Task.sleep(for: .milliseconds(100))
+ XCTAssertEqual(model.selection.start, Coordinate(r: 1, c: 0))
+ XCTAssertEqual(model.selection.end, Coordinate(r: 1, c: 2))
+ }
+ @MainActor func testRapidTypingDuringFocusTransfer() async throws {
+ for delay in [0, 1, 5, 10, 25, 50] {
+ let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://127.0.0.1:1")!))
+ model.page = SheetPage(Sheet: SheetSummary(ID: 1, Title: "Fixture", TabID: 1, TabName: "Sheet 1"), Rows: 4, Cols: 3, Truncated: false, MayWrite: true, MayComment: true, MayShare: false, FillSwatches: [], InkSwatches: [], NumberFormats: [], Warned: nil)
+ let host = NSHostingView(rootView: SheetGrid(model: model))
+ let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), styleMask: [.titled], backing: .buffered, defer: false)
+ window.contentView = host; NSApp.setActivationPolicy(.regular); window.makeKeyAndOrderFront(nil); NSApp.activate()
+ defer { window.orderOut(nil) }
+ try await Task.sleep(for: .milliseconds(200))
+ click(NSPoint(x: 100, y: 350), window: window)
+ try await Task.sleep(for: .milliseconds(100))
+ key("h", code: 4, window: window)
+ if delay > 0 { try await Task.sleep(for: .milliseconds(delay)) }
+ key("e", code: 14, window: window)
+ try await Task.sleep(for: .milliseconds(200))
+ XCTAssertEqual(model.draft, "he", "Typing during focus handoff (\(delay)ms)")
+ }
+ }
+ func testResizePreviewUsesOriginalSizeAndServerBounds() {
+ var preview = SheetResizePreview(axis: "col", index: 0, original: 110)
+ preview.update(translation: 30)
+ XCTAssertEqual(preview.size, 140)
+ preview.update(translation: 40)
+ XCTAssertEqual(preview.size, 150)
+ preview.update(translation: -1000)
+ XCTAssertEqual(preview.size, 16)
+ preview.update(translation: 5000)
+ XCTAssertEqual(preview.size, 2000)
+ }
+ @MainActor func testDragResizeWaitsForReleaseAndRollsBackOnFailure() async throws {
+ for axis in ["col", "row"] {
+ for succeeds in [false, true] {
+ let server = try ResizeFixtureServer()
+ let origin = try await server.start()
+ defer { server.stop() }
+ let model = SheetModel(client: NativeHTTP(origin: succeeds ? origin : URL(string: "http://127.0.0.1:1")!))
+ model.page = SheetPage(Sheet: SheetSummary(ID: 1, Title: "Fixture", TabID: 1, TabName: "Sheet 1"), Rows: 4, Cols: 3, Truncated: false, MayWrite: true, MayComment: true, MayShare: false, FillSwatches: [], InkSwatches: [], NumberFormats: [], Warned: nil)
+ let host = NSHostingView(rootView: SheetGrid(model: model))
+ let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), styleMask: [.titled], backing: .buffered, defer: false)
+ window.contentView = host; NSApp.setActivationPolicy(.regular); window.makeKeyAndOrderFront(nil); NSApp.activate()
+ defer { window.orderOut(nil) }
+ try await Task.sleep(for: .milliseconds(200))
+ func handles(_ view: NSView) -> [SheetResizeHandle.Handle] {
+ (view as? SheetResizeHandle.Handle).map { [$0] } ?? view.subviews.flatMap(handles)
+ }
+ let handle = try XCTUnwrap(handles(host).first { $0.actions?.vertical == (axis == "row") })
+ let start = handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil)
+ let end = NSPoint(x: start.x + (axis == "col" ? 40 : 0), y: start.y - (axis == "row" ? 40 : 0))
+ func send(_ type: NSEvent.EventType, _ point: NSPoint) {
+ window.sendEvent(NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)!)
+ }
+ // Cancel one drag first: no save, and geometry must return to its original position.
+ send(.leftMouseDown, start)
+ send(.leftMouseDragged, end)
+ try await Task.sleep(for: .milliseconds(100))
+ let preview = handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil)
+ XCTAssertEqual(axis == "col" ? preview.x - start.x : start.y - preview.y, 40, accuracy: 1)
+ handle.keyDown(with: NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)!)
+ send(.leftMouseUp, end)
+ try await Task.sleep(for: .milliseconds(100))
+ XCTAssertEqual(server.requests.count, 0)
+ XCTAssertEqual(handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil), start)
+ send(.leftMouseDown, start)
+ try await Task.sleep(for: .milliseconds(50))
+ for step in 1...5 {
+ send(.leftMouseDragged, NSPoint(x: start.x + (end.x - start.x) * Double(step) / 5, y: start.y + (end.y - start.y) * Double(step) / 5))
+ try await Task.sleep(for: .milliseconds(30))
+ }
+ try await Task.sleep(for: .milliseconds(150))
+ XCTAssertFalse(model.busy)
+ XCTAssertEqual(server.requests.count, 0)
+ XCTAssertEqual(model.error, "", "Dragging must not send intermediate requests")
+ send(.leftMouseUp, end)
+ try await Task.sleep(for: .milliseconds(300))
+ if succeeds {
+ XCTAssertEqual(model.error, "")
+ XCTAssertEqual(server.requests, [[axis: 0, "size": axis == "col" ? 150 : 72]])
+ XCTAssertEqual(axis == "col" ? model.sizes.cols?["0"] : model.sizes.rows?["0"], axis == "col" ? 150 : 72)
+ } else {
+ XCTAssertFalse(model.error.isEmpty, "Releasing the \(axis) handle must attempt persistence")
+ XCTAssertNil(model.sizes.cols?["0"])
+ XCTAssertNil(model.sizes.rows?["0"])
+ XCTAssertEqual(handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil), start)
+ }
+ XCTAssertFalse(model.busy)
+ }
+ }
+ }
+ @MainActor private func click(_ point: NSPoint, window: NSWindow) {
+ for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
+ let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)!
+ window.sendEvent(event)
+ }
+ }
+ @MainActor private func key(_ text: String, code: UInt16, window: NSWindow) {
+ let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: text, charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)!
+ window.sendEvent(event)
+ }
+}
+#endif
diff --git a/native/apple/Tests/SheetsKitTests/SheetWireWorkerTests.swift b/native/apple/Tests/SheetsKitTests/SheetWireWorkerTests.swift
new file mode 100644
index 0000000..3331883
--- /dev/null
+++ b/native/apple/Tests/SheetsKitTests/SheetWireWorkerTests.swift
@@ -0,0 +1,39 @@
+import Foundation
+import Testing
+import RastrilloApple
+@testable import SheetsKit
+
+@Test func backgroundBatchesRespectSizeAndRetainEveryCell() async throws {
+ let worker = SheetWireWorker()
+ let writes = (0..<620).map { CellWrite(r: $0, c: 0, raw: String(repeating: "é", count: 4096)) }
+ var offset = 0
+ while offset < writes.count {
+ let batch = try await worker.encode(writes, startingAt: offset)
+ #expect(batch.count > 0 && batch.count <= 500)
+ #expect(batch.data.count <= 1 << 20)
+ let object = try #require(JSONSerialization.jsonObject(with: batch.data) as? [String: Any])
+ let cells = try #require(object["cells"] as? [[String: Any]])
+ #expect(cells.count == batch.count)
+ for (index, cell) in cells.enumerated() {
+ #expect(cell["r"] as? Int == offset + index)
+ #expect(cell["raw"] as? String == writes[offset + index].raw)
+ }
+ offset += batch.count
+ }
+ let response = try await worker.decode(Data(#"{"version":42,"cells":[{"r":3,"c":2,"raw":"=1+1","display":"2"}]}"#.utf8))
+ #expect(response.version == 42)
+ #expect(response.cells?.first?.raw == "=1+1")
+ await #expect(throws: (any Error).self) { try await worker.decode(Data("invalid".utf8)) }
+}
+
+@MainActor @Test func oldStreamCannotClearOrOverwriteCurrentSheet() async {
+ let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!))
+ let point = Coordinate(r: 0, c: 0)
+ model.cells[point] = Cell(r: 0, c: 0, raw: "current", display: "current")
+ model.live = "Live"
+ await model.received(SheetEvent(name: "bye", data: "access-changed"), source: "/api/sheets/old/tabs/old")
+ await model.received(SheetEvent(name: "cells", data: #"{"version":99,"cells":[{"r":0,"c":0,"raw":"old","display":"old"}]}"#), source: "/api/sheets/old/tabs/old")
+ #expect(model.cells[point]?.raw == "current")
+ #expect(model.version == 0)
+ #expect(model.live == "Live")
+}
diff --git a/native/apple/Tests/SheetsWindowTests/WindowRoutingTests.swift b/native/apple/Tests/SheetsWindowTests/WindowRoutingTests.swift
new file mode 100644
index 0000000..648936e
--- /dev/null
+++ b/native/apple/Tests/SheetsWindowTests/WindowRoutingTests.swift
@@ -0,0 +1,27 @@
+#if os(macOS)
+import Foundation
+import Testing
+import RastrilloApple
+@testable import SheetsMac
+
+@MainActor @Test func windowsReuseDocumentsWithinAccountAndOriginOnly() {
+ let windows = SheetsWindows()
+ let first = NativeHTTP(origin: URL(string: "https://first.invalid")!)
+ let second = NativeHTTP(origin: URL(string: "https://second.invalid")!)
+ let document: UInt64 = 7
+ let id = windows.reserve(account: "a", document: document, title: "One", client: first)
+ #expect(windows.reserve(account: "a", document: document, title: "Renamed", client: first) == id)
+ #expect(windows.entries.count == 1)
+ #expect(!windows.canSignOut("a"))
+ #expect(windows.reserve(account: "b", document: document, title: "Other account", client: first) != id)
+ #expect(windows.reserve(account: "a", document: document, title: "Other server", client: second) != id)
+ #expect(windows.entries.count == 3)
+ windows.setOpen(id, false)
+ #expect(!windows.canSignOut("a"))
+ for entry in windows.entries where entry.account == "a" { windows.setOpen(entry.id, false) }
+ #expect(windows.canSignOut("a"))
+ windows.updateRecents([(document, "Current title")], account: "a", client: first)
+ #expect(windows.entries.first(where: { $0.id == id })?.title == "Current title")
+ #expect(windows.entries.first(where: { $0.id == id })?.isOpen == false)
+}
+#endif
diff --git a/native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png
index ac7e7c9..40f5065 100644
Binary files a/native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png and b/native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ
diff --git a/native/ios/Info.plist b/native/ios/Info.plist
new file mode 100644
index 0000000..87efa57
--- /dev/null
+++ b/native/ios/Info.plist
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>$(PRODUCT_NAME)</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+ <key>CFBundleShortVersionString</key>
+ <string>$(MARKETING_VERSION)</string>
+ <key>CFBundleURLTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleTypeRole</key>
+ <string>Editor</string>
+ <key>CFBundleURLName</key>
+ <string>works.oficina.sheets.signin</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>works.oficina.sheets</string>
+ </array>
+ </dict>
+ </array>
+ <key>CFBundleVersion</key>
+ <string>$(CURRENT_PROJECT_VERSION)</string>
+</dict>
+</plist>
diff --git a/native/ios/UITests/NativeUITests.swift b/native/ios/UITests/NativeUITests.swift
index 6235cd3..9c1bfb6 100644
--- a/native/ios/UITests/NativeUITests.swift
+++ b/native/ios/UITests/NativeUITests.swift
@@ -6,7 +6,7 @@ import XCTest
let app = XCUIApplication()
app.launch()
defer { app.terminate() }
- let create = app.buttons["New sheet"]
+ let create = app.buttons["New spreadsheet"]
XCTAssertTrue(create.waitForExistence(timeout: 15))
create.tap()
let formula = app.textFields["Cell contents or formula"]
@@ -78,8 +78,8 @@ import XCTest
let app = XCUIApplication()
app.launch()
defer { app.terminate() }
- XCTAssertTrue(app.buttons["New sheet"].waitForExistence(timeout: 15))
- app.buttons["New sheet"].tap()
+ XCTAssertTrue(app.buttons["New spreadsheet"].waitForExistence(timeout: 15))
+ app.buttons["New spreadsheet"].tap()
let formula = app.textFields["Cell contents or formula"]
XCTAssertTrue(formula.waitForExistence(timeout: 10))
formula.tap()
diff --git a/native/ios/project.yml b/native/ios/project.yml
index 953f5c6..cf4b8ca 100644
--- a/native/ios/project.yml
+++ b/native/ios/project.yml
@@ -8,60 +8,77 @@ packages:
path: ../apple
RastrilloNative:
url: https://amadan.net/rastrillo/native
- revision: a49307c29cf36dff2cf423c3f98b38269ce9a553
+ revision: 229525909e1d6ac3b6a353d1c4d022798b0e9a24
settings:
base:
SWIFT_VERSION: '6.0'
CODE_SIGN_STYLE: Automatic
- MARKETING_VERSION: '0.1.0'
+ MARKETING_VERSION: 0.1.0
CURRENT_PROJECT_VERSION: '1'
targets:
SheetsPhone:
type: application
platform: iOS
sources:
- - ../apple/Sources/SheetsApp
- - Assets.xcassets
+ - ../apple/Sources/SheetsApp
+ - Assets.xcassets
+ - path: ../LICENSE.lucide
+ buildPhase: resources
+ - path: ../PrivacyInfo.xcprivacy
+ buildPhase: resources
dependencies:
- - package: SheetsNative
- product: SheetsKit
- - package: RastrilloNative
- product: RastrilloApple
+ - package: SheetsNative
+ product: SheetsKit
+ - package: RastrilloNative
+ product: RastrilloApple
settings:
base:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
- PRODUCT_BUNDLE_IDENTIFIER: net.amadan.oficina.sheets.ios
- TARGETED_DEVICE_FAMILY: '1,2'
- GENERATE_INFOPLIST_FILE: YES
+ PROVISIONING_PROFILE_SPECIFIER: $(OFICINA_PROVISIONING_PROFILE)
+ PRODUCT_BUNDLE_IDENTIFIER: works.oficina.sheets
+ TARGETED_DEVICE_FAMILY: 1,2
+ GENERATE_INFOPLIST_FILE: true
INFOPLIST_KEY_CFBundleDisplayName: Oficina Sheets
- INFOPLIST_KEY_UILaunchScreen_Generation: YES
- INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown
-
+ INFOPLIST_KEY_UILaunchScreen_Generation: true
+ INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
+ UIInterfaceOrientationPortraitUpsideDown
+ info:
+ path: Info.plist
+ properties:
+ CFBundleURLTypes:
+ - CFBundleURLName: works.oficina.sheets.signin
+ CFBundleURLSchemes:
+ - works.oficina.sheets
+ CFBundleTypeRole: Editor
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
SheetsReviewPhone:
type: application
platform: iOS
- sources: [Review]
+ sources:
+ - Review
dependencies:
- - package: SheetsNative
- product: SheetsKit
- - package: RastrilloNative
- product: RastrilloApple
+ - package: SheetsNative
+ product: SheetsKit
+ - package: RastrilloNative
+ product: RastrilloApple
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.amadan.oficina.sheets.review
- TARGETED_DEVICE_FAMILY: '1,2'
- GENERATE_INFOPLIST_FILE: YES
- INFOPLIST_KEY_UILaunchScreen_Generation: YES
-
+ TARGETED_DEVICE_FAMILY: 1,2
+ GENERATE_INFOPLIST_FILE: true
+ INFOPLIST_KEY_UILaunchScreen_Generation: true
SheetsPhoneUITests:
type: bundle.ui-testing
platform: iOS
- sources: [UITests]
+ sources:
+ - UITests
dependencies:
- - target: SheetsReviewPhone
+ - target: SheetsReviewPhone
settings:
base:
- GENERATE_INFOPLIST_FILE: YES
+ GENERATE_INFOPLIST_FILE: true
TEST_TARGET_NAME: SheetsReviewPhone
schemes:
SheetsReviewPhone:
@@ -69,4 +86,5 @@ schemes:
targets:
SheetsReviewPhone: all
test:
- targets: [SheetsPhoneUITests]
+ targets:
+ - SheetsPhoneUITests
diff --git a/native/mac-updates.json b/native/mac-updates.json
new file mode 100644
index 0000000..616a4e4
--- /dev/null
+++ b/native/mac-updates.json
@@ -0,0 +1,6 @@
+{
+ "bundle_id": "net.amadan.oficina.sheets",
+ "feed_url": "https://raw.amadan.net/oficina/native-releases/main/sheets/appcast.xml",
+ "public_ed_key": "/FxO7cZnM7P5c9ZrUPUJvpCnR65sNmrzte+I5vHarE8=",
+ "keychain_account": "oficina"
+}
diff --git a/native/macos/AppIcon.icns b/native/macos/AppIcon.icns
new file mode 100644
index 0000000..9198a29
Binary files /dev/null and b/native/macos/AppIcon.icns differ
diff --git a/native/macos/AppStore.entitlements b/native/macos/AppStore.entitlements
new file mode 100644
index 0000000..a046386
--- /dev/null
+++ b/native/macos/AppStore.entitlements
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>com.apple.security.app-sandbox</key>
+ <true/>
+ <key>com.apple.security.files.user-selected.read-write</key>
+ <true/>
+ <key>com.apple.security.network.client</key>
+ <true/>
+</dict>
+</plist>
diff --git a/native/macos/Info.plist b/native/macos/Info.plist
new file mode 100644
index 0000000..aa4219b
--- /dev/null
+++ b/native/macos/Info.plist
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleDisplayName</key>
+ <string>Oficina Sheets</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIconFile</key>
+ <string>AppIcon</string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>Oficina Sheets</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+ <key>CFBundleShortVersionString</key>
+ <string>$(MARKETING_VERSION)</string>
+ <key>CFBundleURLTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleTypeRole</key>
+ <string>Editor</string>
+ <key>CFBundleURLName</key>
+ <string>works.oficina.sheets.signin</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>works.oficina.sheets</string>
+ </array>
+ </dict>
+ </array>
+ <key>CFBundleVersion</key>
+ <string>$(CURRENT_PROJECT_VERSION)</string>
+ <key>LSApplicationCategoryType</key>
+ <string>public.app-category.productivity</string>
+ <key>NSHighResolutionCapable</key>
+ <true/>
+</dict>
+</plist>
diff --git a/native/macos/SheetsMacStore.xcodeproj/project.pbxproj b/native/macos/SheetsMacStore.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..6cfc4a7
--- /dev/null
+++ b/native/macos/SheetsMacStore.xcodeproj/project.pbxproj
@@ -0,0 +1,391 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 5CC39F6614938AE329384AB8 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 54261A6D711BA4C90D042C6A /* AppIcon.icns */; };
+ 603E79126E29D73CD628EDED /* LICENSE.lucide in Resources */ = {isa = PBXBuildFile; fileRef = 5AA27588A5A207F206A0F3F6 /* LICENSE.lucide */; };
+ 9C78334F6EDBA092C0D0E169 /* SheetsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 916E654E0C8D3B86BB58C018 /* SheetsApp.swift */; };
+ B09E36EA52DF0E19FC743C56 /* SheetsKit in Frameworks */ = {isa = PBXBuildFile; productRef = CD52B595B2129268F78A0A8A /* SheetsKit */; };
+ C52F3F48EAE4BD0A51D2BE94 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8AA0125DF292C29086DFC83E /* PrivacyInfo.xcprivacy */; };
+ CAA9732AF6C006EFA3A6EEEC /* SheetsAccountWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B6A575CBF89AE7854830BA2 /* SheetsAccountWindow.swift */; };
+ DBF0A51B0431713C3FFB89A9 /* RastrilloApple in Frameworks */ = {isa = PBXBuildFile; productRef = 6334833E1C593E852E1F7FD4 /* RastrilloApple */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 12E225ACCFB2785464CCBCB8 /* apple */ = {isa = PBXFileReference; lastKnownFileType = folder; name = apple; path = ../apple; sourceTree = SOURCE_ROOT; };
+ 255C08D1CA0763B1DE054572 /* SheetsMacStore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SheetsMacStore.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 4B6A575CBF89AE7854830BA2 /* SheetsAccountWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetsAccountWindow.swift; sourceTree = "<group>"; };
+ 54261A6D711BA4C90D042C6A /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = "<group>"; };
+ 5AA27588A5A207F206A0F3F6 /* LICENSE.lucide */ = {isa = PBXFileReference; path = LICENSE.lucide; sourceTree = "<group>"; };
+ 8AA0125DF292C29086DFC83E /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
+ 916E654E0C8D3B86BB58C018 /* SheetsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetsApp.swift; sourceTree = "<group>"; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 5D1F99FAE10817C6F9626A2A /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ B09E36EA52DF0E19FC743C56 /* SheetsKit in Frameworks */,
+ DBF0A51B0431713C3FFB89A9 /* RastrilloApple in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 00DE240DA7582B0C0B555805 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 255C08D1CA0763B1DE054572 /* SheetsMacStore.app */,
+ );
+ name = Products;
+ sourceTree = "<group>";
+ };
+ 31394D7FCB46710873A9926E /* native */ = {
+ isa = PBXGroup;
+ children = (
+ 5AA27588A5A207F206A0F3F6 /* LICENSE.lucide */,
+ 8AA0125DF292C29086DFC83E /* PrivacyInfo.xcprivacy */,
+ );
+ name = native;
+ path = ..;
+ sourceTree = "<group>";
+ };
+ 6581DFA207E84F766998E653 /* Packages */ = {
+ isa = PBXGroup;
+ children = (
+ 12E225ACCFB2785464CCBCB8 /* apple */,
+ );
+ name = Packages;
+ sourceTree = "<group>";
+ };
+ B8C7D78FF3DFFC221BBB89D3 /* SheetsApp */ = {
+ isa = PBXGroup;
+ children = (
+ 4B6A575CBF89AE7854830BA2 /* SheetsAccountWindow.swift */,
+ 916E654E0C8D3B86BB58C018 /* SheetsApp.swift */,
+ );
+ name = SheetsApp;
+ path = ../apple/Sources/SheetsApp;
+ sourceTree = "<group>";
+ };
+ E5EC2C9789E2CB25A2EF75DC = {
+ isa = PBXGroup;
+ children = (
+ 54261A6D711BA4C90D042C6A /* AppIcon.icns */,
+ 31394D7FCB46710873A9926E /* native */,
+ 6581DFA207E84F766998E653 /* Packages */,
+ B8C7D78FF3DFFC221BBB89D3 /* SheetsApp */,
+ 00DE240DA7582B0C0B555805 /* Products */,
+ );
+ sourceTree = "<group>";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ BBF71CE12A169C15448DFE17 /* SheetsMacStore */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = CA4EB5575C1FF80222F682F0 /* Build configuration list for PBXNativeTarget "SheetsMacStore" */;
+ buildPhases = (
+ 234C25F498941E102ED36418 /* Sources */,
+ 8EFEC10D961D3B57455AE206 /* Resources */,
+ 5D1F99FAE10817C6F9626A2A /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = SheetsMacStore;
+ packageProductDependencies = (
+ CD52B595B2129268F78A0A8A /* SheetsKit */,
+ 6334833E1C593E852E1F7FD4 /* RastrilloApple */,
+ );
+ productName = SheetsMacStore;
+ productReference = 255C08D1CA0763B1DE054572 /* SheetsMacStore.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ A3F5B33CA29DABC65807207B /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1430;
+ TargetAttributes = {
+ BBF71CE12A169C15448DFE17 = {
+ ProvisioningStyle = Automatic;
+ };
+ };
+ };
+ buildConfigurationList = 594CF8F18C652F85BD0F8E62 /* Build configuration list for PBXProject "SheetsMacStore" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ Base,
+ en,
+ );
+ mainGroup = E5EC2C9789E2CB25A2EF75DC;
+ minimizedProjectReferenceProxies = 1;
+ packageReferences = (
+ BA0E7C8C83BC0368D29C5E44 /* XCRemoteSwiftPackageReference "native" */,
+ 2526B688BB50E918D848EA77 /* XCLocalSwiftPackageReference "../apple" */,
+ );
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 00DE240DA7582B0C0B555805 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ BBF71CE12A169C15448DFE17 /* SheetsMacStore */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 8EFEC10D961D3B57455AE206 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 5CC39F6614938AE329384AB8 /* AppIcon.icns in Resources */,
+ 603E79126E29D73CD628EDED /* LICENSE.lucide in Resources */,
+ C52F3F48EAE4BD0A51D2BE94 /* PrivacyInfo.xcprivacy in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 234C25F498941E102ED36418 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ CAA9732AF6C006EFA3A6EEEC /* SheetsAccountWindow.swift in Sources */,
+ 9C78334F6EDBA092C0D0E169 /* SheetsApp.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 0FE8BB7C08DC50F51165F494 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = AppStore.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = works.oficina.sheets;
+ PROVISIONING_PROFILE_SPECIFIER = "$(OFICINA_PROVISIONING_PROFILE)";
+ SDKROOT = macosx;
+ };
+ name = Release;
+ };
+ 4E107C5D116C49C05C2688EE /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ CURRENT_PROJECT_VERSION = 3;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 0.1.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 6.0;
+ };
+ name = Release;
+ };
+ C50FCE2D58FB2E406FAD5F92 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = AppStore.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = works.oficina.sheets;
+ PROVISIONING_PROFILE_SPECIFIER = "$(OFICINA_PROVISIONING_PROFILE)";
+ SDKROOT = macosx;
+ };
+ name = Debug;
+ };
+ F432AD9AEA5B79502BDCE34D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ CURRENT_PROJECT_VERSION = 3;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 0.1.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 6.0;
+ };
+ name = Debug;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 594CF8F18C652F85BD0F8E62 /* Build configuration list for PBXProject "SheetsMacStore" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ F432AD9AEA5B79502BDCE34D /* Debug */,
+ 4E107C5D116C49C05C2688EE /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ CA4EB5575C1FF80222F682F0 /* Build configuration list for PBXNativeTarget "SheetsMacStore" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ C50FCE2D58FB2E406FAD5F92 /* Debug */,
+ 0FE8BB7C08DC50F51165F494 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 2526B688BB50E918D848EA77 /* XCLocalSwiftPackageReference "../apple" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../apple;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ BA0E7C8C83BC0368D29C5E44 /* XCRemoteSwiftPackageReference "native" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://amadan.net/rastrillo/native";
+ requirement = {
+ kind = revision;
+ revision = 229525909e1d6ac3b6a353d1c4d022798b0e9a24;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 6334833E1C593E852E1F7FD4 /* RastrilloApple */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = BA0E7C8C83BC0368D29C5E44 /* XCRemoteSwiftPackageReference "native" */;
+ productName = RastrilloApple;
+ };
+ CD52B595B2129268F78A0A8A /* SheetsKit */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = SheetsKit;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = A3F5B33CA29DABC65807207B /* Project object */;
+}
diff --git a/native/macos/SheetsMacStore.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/native/macos/SheetsMacStore.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/native/macos/SheetsMacStore.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+ version = "1.0">
+ <FileRef
+ location = "self:">
+ </FileRef>
+</Workspace>
diff --git a/native/macos/project.yml b/native/macos/project.yml
new file mode 100644
index 0000000..954e5bb
--- /dev/null
+++ b/native/macos/project.yml
@@ -0,0 +1,54 @@
+name: SheetsMacStore
+options:
+ deploymentTarget:
+ macOS: '26.0'
+packages:
+ SheetsNative:
+ path: ../apple
+ RastrilloNative:
+ url: https://amadan.net/rastrillo/native
+ revision: 229525909e1d6ac3b6a353d1c4d022798b0e9a24
+settings:
+ base:
+ SWIFT_VERSION: '6.0'
+ CODE_SIGN_STYLE: Automatic
+ MARKETING_VERSION: 0.1.0
+ CURRENT_PROJECT_VERSION: '3'
+ ENABLE_HARDENED_RUNTIME: true
+targets:
+ SheetsMacStore:
+ type: application
+ platform: macOS
+ sources:
+ - ../apple/Sources/SheetsApp
+ - path: AppIcon.icns
+ buildPhase: resources
+ - path: ../LICENSE.lucide
+ buildPhase: resources
+ - path: ../PrivacyInfo.xcprivacy
+ buildPhase: resources
+ dependencies:
+ - package: SheetsNative
+ product: SheetsKit
+ - package: RastrilloNative
+ product: RastrilloApple
+ info:
+ path: Info.plist
+ properties:
+ CFBundleDisplayName: Oficina Sheets
+ CFBundleName: Oficina Sheets
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
+ CFBundleIconFile: AppIcon
+ LSApplicationCategoryType: public.app-category.productivity
+ NSHighResolutionCapable: true
+ CFBundleURLTypes:
+ - CFBundleURLName: works.oficina.sheets.signin
+ CFBundleURLSchemes:
+ - works.oficina.sheets
+ CFBundleTypeRole: Editor
+ settings:
+ base:
+ PROVISIONING_PROFILE_SPECIFIER: $(OFICINA_PROVISIONING_PROFILE)
+ PRODUCT_BUNDLE_IDENTIFIER: works.oficina.sheets
+ CODE_SIGN_ENTITLEMENTS: AppStore.entitlements
diff --git a/native/scripts/build-mac.sh b/native/scripts/build-mac.sh
index 6c300f7..6c81843 100755
--- a/native/scripts/build-mac.sh
+++ b/native/scripts/build-mac.sh
@@ -2,4 +2,5 @@
set -eu
cd "$(dirname "$0")/.."
swift package --package-path apple resolve
-python3 apple/.build/checkouts/native/tools/bundle-mac.py . Sheets
+shared_native="${OFICINA_NATIVE_PACKAGE_PATH:-apple/.build/checkouts/native}"
+python3 "$shared_native/tools/bundle-mac.py" . Sheets "$@"
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/scripts/test-mac-bundle.py b/native/scripts/test-mac-bundle.py
new file mode 100644
index 0000000..3096f67
--- /dev/null
+++ b/native/scripts/test-mac-bundle.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""Launch a relocated release archive without access to SwiftPM build fallbacks.
+
+Run on a logged-in Mac. No files in the source/build directories are modified.
+The test terminates only the app process it launches.
+"""
+import argparse
+import json
+from pathlib import Path
+import plistlib
+import subprocess
+import tempfile
+import time
+
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument("archive", type=Path)
+args = parser.parse_args()
+with tempfile.TemporaryDirectory(prefix="oficina-sheets-launch-") as directory:
+ root = Path(directory)
+ subprocess.run(["ditto", "-x", "-k", str(args.archive.resolve()), str(root)], check=True)
+ apps = list(root.glob("*.app"))
+ assert len(apps) == 1, "Expected one application in release archive"
+ app = apps[0]
+ info = plistlib.loads((app / "Contents/Info.plist").read_bytes())
+ executable = app / "Contents/MacOS" / info["CFBundleExecutable"]
+ strings = subprocess.check_output(["strings", str(executable)], text=True).splitlines()
+ fallbacks = {line.split("/.build/", 1)[0] + "/.build" for line in strings
+ if line.startswith("/") and "/.build/" in line and line.endswith(".bundle")}
+ assert fallbacks, "No SwiftPM fallback paths found; review test coverage"
+ profile = '(version 1)(allow default)' + ''.join(
+ f'(deny file-read* (subpath {json.dumps(path)}))' for path in sorted(fallbacks))
+ probe = root / "windows.swift"
+ probe.write_text('''import CoreGraphics
+import Foundation
+let pid = Int(CommandLine.arguments[1])!
+let windows = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) as? [[String: Any]] ?? []
+let found = windows.contains { window in
+ guard window[kCGWindowOwnerPID as String] as? Int == pid,
+ window[kCGWindowLayer as String] as? Int == 0,
+ let bounds = window[kCGWindowBounds as String] as? [String: Any],
+ let width = bounds["Width"] as? Double,
+ let height = bounds["Height"] as? Double else { return false }
+ return width >= 300 && height >= 100 // Signed-out account chooser is compact.
+}
+exit(found ? 0 : 1)
+''')
+ subprocess.run(["swiftc", str(probe), "-o", str(root / "windows")], check=True)
+ with (root / "launch.log").open("w+") as log:
+ process = subprocess.Popen(["sandbox-exec", "-p", profile, str(executable),
+ "-ApplePersistenceIgnoreState", "YES"], stdout=log, stderr=log)
+ try:
+ found = False
+ deadline = time.monotonic() + 12
+ while time.monotonic() < deadline:
+ if process.poll() is not None:
+ log.seek(0)
+ raise RuntimeError(f"App exited with {process.returncode}: {log.read()}")
+ if subprocess.run([str(root / "windows"), str(process.pid)]).returncode == 0:
+ found = True
+ time.sleep(0.25)
+ assert found, "App survived but did not create a normal window"
+ print(f'PASS: {info["CFBundleName"]} {info["CFBundleShortVersionString"]} '
+ f'({info["CFBundleVersion"]}) opens a window with build-directory access denied')
+ finally:
+ if process.poll() is None:
+ process.terminate()
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
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)
+ }
+ }
+}