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