diff --git a/ios/App/AppUITests/CloudFlowUITests.swift b/ios/App/AppUITests/CloudFlowUITests.swift index a827adb..f8e65fa 100644 --- a/ios/App/AppUITests/CloudFlowUITests.swift +++ b/ios/App/AppUITests/CloudFlowUITests.swift @@ -1,10 +1,43 @@ import XCTest +/// The account-backed flows sign in to ZenNotes Cloud as the dedicated E2E +/// account and read notes its vault already holds (the desktop and Android +/// sync proofs), so no other account can pass them. Without that account's +/// ZENNOTES_CLOUD_E2E_EMAIL and ZENNOTES_CLOUD_E2E_PASSWORD they skip instead +/// of failing, so the whole class can run in the release gate; xcodebuild +/// hands this runner any variable prefixed TEST_RUNNER_ with the prefix +/// removed. The route into Cloud settings needs no account and runs anyway. +/// +/// Taps that go through the ensō menu and the ••• sheet look for the match +/// that can take the tap, not the first one: WebKit exposes the whole open +/// note, and the welcome note's bold "More", "Settings" and "Browse" sit +/// earlier in the tree than the controls with those labels. With the menu +/// open, the first "More" was the note's, under the menu's backdrop at +/// x=51, so the tap missed and every Cloud flow failed before reaching Cloud. final class CloudFlowUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false } + func testCloudSettingsOpenThroughTheMoreSheet() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + guard app.windows.firstMatch.frame.width < 768 else { + throw XCTSkip("phone shell only: the iPad runs the desktop layout, which has no ensō menu") + } + + try openCloudSettings(in: app) + + let screen = ["Connect ZenNotes Cloud", "Cancel sign-in", "Disconnect"] + .map { element(label: $0, in: app) } + let deadline = Date().addingTimeInterval(10) + while !screen.contains(where: \.exists) && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + } + XCTAssertTrue(screen.contains(where: \.exists), "Settings › Cloud did not open from the ••• sheet") + } + func testIPadCanOpenRemoteVaultManagerFromSettings() throws { let app = XCUIApplication() app.launch() @@ -32,10 +65,11 @@ final class CloudFlowUITests: XCTestCase { } func testCloudSyncAndBackupFlow() throws { + let account = try cloudAccount() let app = XCUIApplication() app.launch() - ensureLinkedCloudVault(in: app, linkLabel: "Create and link") + try ensureLinkedCloudVault(in: app, account: account, linkLabel: "Create and link") let syncNow = element(label: "Sync now", in: app) scrollUntilHittable(syncNow, in: app) @@ -54,10 +88,11 @@ final class CloudFlowUITests: XCTestCase { } func testDesktopNoteAppearsAfterSync() throws { + let account = try cloudAccount() let app = XCUIApplication() app.launch() - ensureLinkedCloudVault(in: app) + try ensureLinkedCloudVault(in: app, account: account) let syncNow = element(label: "Sync now", in: app) scrollUntilHittable(syncNow, in: app) @@ -70,13 +105,7 @@ final class CloudFlowUITests: XCTestCase { XCTAssertTrue(done.waitForExistence(timeout: 5)) done.tap() - let openMenu = app.buttons["Open menu"] - XCTAssertTrue(openMenu.waitForExistence(timeout: 5)) - openMenu.tap() - - let browse = element(label: "Browse", in: app) - XCTAssertTrue(browse.waitForExistence(timeout: 5)) - browse.tap() + try openBrowse(in: app) let syncedNote = hittableButton(label: "Desktop to mobile — live sync", in: app) XCTAssertTrue(syncedNote.waitForExistence(timeout: 10)) @@ -86,9 +115,7 @@ final class CloudFlowUITests: XCTestCase { let syncedBody = element(label: "Created on the desktop app.", in: app) XCTAssertTrue(syncedBody.waitForExistence(timeout: 10)) - openMenu.tap() - XCTAssertTrue(browse.waitForExistence(timeout: 5)) - browse.tap() + try openBrowse(in: app) let androidNote = hittableButton(label: "Meeting notes — product sync", in: app) XCTAssertTrue(androidNote.waitForExistence(timeout: 10)) @@ -100,16 +127,17 @@ final class CloudFlowUITests: XCTestCase { } func testAutomaticCloudSyncPullsDesktopAndAndroidChanges() throws { + let account = try cloudAccount() let app = XCUIApplication() app.launch() - ensureLinkedCloudVault(in: app, forceReconnect: true) + try ensureLinkedCloudVault(in: app, account: account, forceReconnect: true) let done = element(label: "Done", in: app) XCTAssertTrue(done.waitForExistence(timeout: 5)) done.tap() - openBrowse(in: app) + try openBrowse(in: app) let desktopProof = hittableButton(label: "Automatic sync proof - desktop", in: app) XCTAssertTrue(desktopProof.waitForExistence(timeout: 60)) @@ -119,10 +147,11 @@ final class CloudFlowUITests: XCTestCase { } func testAutomaticCloudSyncPushesIOSChange() throws { + let account = try cloudAccount() let app = XCUIApplication() app.launch() - ensureLinkedCloudVault(in: app) + try ensureLinkedCloudVault(in: app, account: account) let done = element(label: "Done", in: app) XCTAssertTrue(done.waitForExistence(timeout: 5)) @@ -132,13 +161,8 @@ final class CloudFlowUITests: XCTestCase { XCTAssertTrue(openMenu.waitForExistence(timeout: 10)) openMenu.tap() - let new = element(label: "New", in: app) - XCTAssertTrue(new.waitForExistence(timeout: 5)) - new.tap() - - let newNote = element(label: "New note", in: app) - XCTAssertTrue(newNote.waitForExistence(timeout: 5)) - newNote.tap() + try hittable(label: "New", in: app, failure: "the ensō menu did not open").tap() + try hittable(label: "New note", in: app, failure: "the New sheet has no New note row").tap() let titleInput = app.textFields["Untitled"] XCTAssertTrue(titleInput.waitForExistence(timeout: 10)) @@ -154,22 +178,23 @@ final class CloudFlowUITests: XCTestCase { // The edit must trigger an automatic push: back on the Cloud screen, // the status only reads up to date after a successful sync run. - openCloudSettings(in: app) + try openCloudSettings(in: app) let pushed = element(label: "Everything is up to date.", in: app) XCTAssertTrue(pushed.waitForExistence(timeout: 90)) } func testPublishesExistingNote() throws { + let account = try cloudAccount() let app = XCUIApplication() app.launch() - ensureLinkedCloudVault(in: app) + try ensureLinkedCloudVault(in: app, account: account) let done = element(label: "Done", in: app) XCTAssertTrue(done.waitForExistence(timeout: 5)) done.tap() - openBrowse(in: app) + try openBrowse(in: app) let note = hittableButton(label: "Automatic sync proof - desktop", in: app) XCTAssertTrue(note.waitForExistence(timeout: 10)) @@ -180,19 +205,18 @@ final class CloudFlowUITests: XCTestCase { XCTAssertTrue(openMenu.waitForExistence(timeout: 5)) openMenu.tap() - let publish = element(label: "Publish", in: app) - XCTAssertTrue(publish.waitForExistence(timeout: 5)) - publish.tap() + try hittable(label: "Publish", in: app, failure: "the ensō menu has no Publish item").tap() let success = element(label: "Public note updated. Link copied.", in: app) XCTAssertTrue(success.waitForExistence(timeout: 15)) } func testPublishesNoteWithSyncedAttachment() throws { + let account = try cloudAccount() let app = XCUIApplication() app.launch() - ensureLinkedCloudVault(in: app) + try ensureLinkedCloudVault(in: app, account: account) let syncNow = element(label: "Sync now", in: app) scrollUntilHittable(syncNow, in: app) @@ -205,7 +229,7 @@ final class CloudFlowUITests: XCTestCase { XCTAssertTrue(done.waitForExistence(timeout: 5)) done.tap() - openBrowse(in: app) + try openBrowse(in: app) let note = hittableButton(label: "Cloud attachment publishing proof", in: app) XCTAssertTrue(note.waitForExistence(timeout: 15)) @@ -216,24 +240,46 @@ final class CloudFlowUITests: XCTestCase { XCTAssertTrue(openMenu.waitForExistence(timeout: 5)) openMenu.tap() - let publish = element(label: "Publish", in: app) - XCTAssertTrue(publish.waitForExistence(timeout: 5)) - publish.tap() + try hittable(label: "Publish", in: app, failure: "the ensō menu has no Publish item").tap() let success = element(label: "Public note updated. Link copied.", in: app) XCTAssertTrue(success.waitForExistence(timeout: 20)) } + private struct CloudAccount { + let email: String + let password: String + } + + /// The E2E account's credentials, or a skip naming what is missing. Asked + /// before launch, so a run without them costs nothing. + private func cloudAccount() throws -> CloudAccount { + let environment = ProcessInfo.processInfo.environment + let email = environment["ZENNOTES_CLOUD_E2E_EMAIL"] ?? "" + let password = environment["ZENNOTES_CLOUD_E2E_PASSWORD"] ?? "" + let missing = [("ZENNOTES_CLOUD_E2E_EMAIL", email), ("ZENNOTES_CLOUD_E2E_PASSWORD", password)] + .filter { $0.1.isEmpty } + .map(\.0) + guard missing.isEmpty else { + throw XCTSkip( + "Cloud flow needs the ZenNotes Cloud E2E account: \(missing.joined(separator: " and ")) not set. " + + "Pass TEST_RUNNER_ZENNOTES_CLOUD_E2E_EMAIL and TEST_RUNNER_ZENNOTES_CLOUD_E2E_PASSWORD to xcodebuild." + ) + } + return CloudAccount(email: email, password: password) + } + /// Shared prologue: open Settings → Cloud, connect the account if needed, /// and make sure the local vault is linked. Returns with the Cloud screen /// open and "Sync now" present. private func ensureLinkedCloudVault( in app: XCUIApplication, + account: CloudAccount, linkLabel: String = "Link selected vault", forceReconnect: Bool = false - ) { - openCloudSettings(in: app) - connectCloudAccountIfNeeded(in: app, forceReconnect: forceReconnect) + ) throws { + try openCloudSettings(in: app) + connectCloudAccountIfNeeded(in: app, account: account, forceReconnect: forceReconnect) let syncNow = element(label: "Sync now", in: app) if !syncNow.waitForExistence(timeout: 5) { @@ -246,26 +292,21 @@ final class CloudFlowUITests: XCTestCase { XCTAssertTrue(syncNow.waitForExistence(timeout: 15)) } - private func openCloudSettings(in app: XCUIApplication) { - + private func openCloudSettings(in app: XCUIApplication) throws { let openMenu = app.buttons["Open menu"] XCTAssertTrue(openMenu.waitForExistence(timeout: 10)) openMenu.tap() - let more = element(label: "More", in: app) - XCTAssertTrue(more.waitForExistence(timeout: 3)) - more.tap() - - let settings = element(label: "Settings", in: app) - XCTAssertTrue(settings.waitForExistence(timeout: 3)) - settings.tap() - - let cloud = element(label: "Cloud", in: app) - XCTAssertTrue(cloud.waitForExistence(timeout: 3)) - cloud.tap() + try hittable(label: "More", in: app, failure: "the ensō menu did not open").tap() + try hittable(label: "Settings", in: app, failure: "the ••• sheet has no Settings row").tap() + try hittable(label: "Cloud", in: app, failure: "Settings has no Cloud section").tap() } - private func connectCloudAccountIfNeeded(in app: XCUIApplication, forceReconnect: Bool = false) { + private func connectCloudAccountIfNeeded( + in app: XCUIApplication, + account: CloudAccount, + forceReconnect: Bool = false + ) { let connect = element(label: "Connect ZenNotes Cloud", in: app) let disconnect = element(label: "Disconnect", in: app) let cancelSignIn = element(label: "Cancel sign-in", in: app) @@ -290,23 +331,13 @@ final class CloudFlowUITests: XCTestCase { let email = element(label: "Email address", in: safari) if email.waitForExistence(timeout: 10) { - let environment = ProcessInfo.processInfo.environment - guard - let cloudEmail = environment["ZENNOTES_CLOUD_E2E_EMAIL"], - !cloudEmail.isEmpty, - let cloudPassword = environment["ZENNOTES_CLOUD_E2E_PASSWORD"], - !cloudPassword.isEmpty - else { - XCTFail("Set ZENNOTES_CLOUD_E2E_EMAIL and ZENNOTES_CLOUD_E2E_PASSWORD for a fresh Cloud UI-test login.") - return - } email.tap() - email.typeText(cloudEmail) + email.typeText(account.email) let password = safari.secureTextFields["Password"] XCTAssertTrue(password.waitForExistence(timeout: 3)) email.typeText("\t") - password.typeText("\(cloudPassword)\n") + password.typeText("\(account.password)\n") } let authorize = element(label: "Authorize", in: safari) @@ -321,14 +352,12 @@ final class CloudFlowUITests: XCTestCase { XCTAssertTrue(disconnect.waitForExistence(timeout: 15)) } - private func openBrowse(in app: XCUIApplication) { + private func openBrowse(in app: XCUIApplication) throws { let openMenu = app.buttons["Open menu"] XCTAssertTrue(openMenu.waitForExistence(timeout: 5)) openMenu.tap() - let browse = element(label: "Browse", in: app) - XCTAssertTrue(browse.waitForExistence(timeout: 5)) - browse.tap() + try hittable(label: "Browse", in: app, failure: "the ensō menu did not open").tap() } private func scrollUntilHittable(_ element: XCUIElement, in app: XCUIApplication) { @@ -354,4 +383,32 @@ final class CloudFlowUITests: XCTestCase { return matches.element(boundBy: max(matches.count - 1, 0)) } + + /// Any element type: the ensō menu's items are role=menuitem. + private func hittable( + label: String, + in app: XCUIApplication, + failure: @autoclosure () -> String + ) throws -> XCUIElement { + try XCTUnwrap(hittableMatch(NSPredicate(format: "label == %@", label), in: app), failure()) + } + + /// Polls for a match that is on screen and tappable: the menu and the + /// sheets mount a moment after the tap that opens them, and note text + /// under their backdrop must not win. + private func hittableMatch( + _ predicate: NSPredicate, + in app: XCUIApplication, + timeout: TimeInterval = 10 + ) -> XCUIElement? { + let deadline = Date().addingTimeInterval(timeout) + repeat { + let matches = app.descendants(matching: .any).matching(predicate) + for index in 0.. String + ) throws -> XCUIElement { + try XCTUnwrap(hittableMatch(NSPredicate(format: "label == %@", label), in: app), failure()) + } + + /// Polls for a match that is on screen and tappable: the menu mounts a + /// moment after the tap that opens it, and note text under its backdrop + /// must not win. + private func hittableMatch( + _ predicate: NSPredicate, + in app: XCUIApplication, + timeout: TimeInterval = 5 + ) -> XCUIElement? { + let deadline = Date().addingTimeInterval(timeout) + repeat { + let matches = app.descendants(matching: .any).matching(predicate) + for index in 0..=3.0.0 <4.0.0", - "immutable": "^4.0.0", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/@excalidraw/laser-pointer": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz", @@ -1806,6 +1783,294 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", @@ -3478,6 +3743,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3640,6 +3906,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3678,6 +3945,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3826,6 +4094,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -3850,6 +4119,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -4908,6 +5178,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4966,6 +5237,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5370,6 +5642,12 @@ "pica": "^7.1.0" } }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "license": "MIT" + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -5428,6 +5706,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -5481,6 +5760,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5500,6 +5780,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5512,6 +5793,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7120,6 +7402,13 @@ "double-bits": "^1.1.0" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.55", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", @@ -7134,6 +7423,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7299,6 +7589,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -7745,6 +8036,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -8094,6 +8386,54 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sass": { + "version": "1.103.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.103.1.tgz", + "integrity": "sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==", + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/sax": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", @@ -8237,6 +8577,16 @@ "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8252,16 +8602,6 @@ "node": ">=8" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -8515,6 +8855,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" diff --git a/package.json b/package.json index bbb300d..6823b35 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,8 @@ }, "overrides": { "@excalidraw/excalidraw": { - "nanoid": "3.3.18" + "nanoid": "3.3.18", + "sass": "1.103.1" }, "@excalidraw/mermaid-to-excalidraw": { "@mermaid-js/parser": "1.2.1", diff --git a/src/bridge/folder-picker.test.ts b/src/bridge/folder-picker.test.ts index 4d24aad..16cf149 100644 --- a/src/bridge/folder-picker.test.ts +++ b/src/bridge/folder-picker.test.ts @@ -3,7 +3,7 @@ import { test } from 'node:test' import { loadMobileModule } from '../../tooling/load-mobile-module.ts' // The real folder-picker.ts, with the native FolderPicker and the storage -// tier replaced, so each test sees exactly when the Files picker would open. +// tier replaced, so each test sees which bookmark iOS is asked to resolve. const store = new Map() Object.defineProperty(globalThis, 'localStorage', { configurable: true, @@ -14,10 +14,34 @@ Object.defineProperty(globalThis, 'localStorage', { } }) +// The single slot every earlier version kept its one folder vault in. +const SLOT_KEY = 'zn-mobile:external-vault' + type PickResult = { cancelled: boolean; url?: string; name?: string; bookmark?: string } -const notes: PickResult = { cancelled: false, url: 'file:///Notes', name: 'Notes', bookmark: 'bookmark-notes' } -let nextPick: PickResult = notes +type Resolved = { url: string; name: string; bookmark?: string } + +const ICLOUD_DOCUMENTS = 'file:///private/var/mobile/Library/Mobile%20Documents/com~apple~CloudDocs/Documents/' +const ON_DEVICE = 'file:///private/var/mobile/Containers/Shared/AppGroup/1F2E/File%20Provider%20Storage' +const documents = { name: 'Documents', bookmark: 'bookmark-documents' } +const pickNotes: PickResult = { + cancelled: false, + url: `${ON_DEVICE}/Notes/`, + name: 'Notes', + bookmark: 'bookmark-notes' +} +const pickWork: PickResult = { + cancelled: false, + url: `${ON_DEVICE}/Work/`, + name: 'Work', + bookmark: 'bookmark-work' +} + +let nextPick: PickResult = pickNotes let picks = 0 +const resolvable = new Map() +const resolveCalls: string[] = [] +// Runs while iOS is resolving a bookmark, before the answer comes back. +let duringResolve: (() => void) | null = null const tiers: string[] = [] const picker = await loadMobileModule('./src/bridge/folder-picker.ts', { @@ -27,101 +51,233 @@ const picker = await loadMobileModule('./src/bridge/folder-picker.ts', { picks += 1 return nextPick }, - resolveBookmark: async () => { - throw new Error('not reached by a pick') + resolveBookmark: async ({ bookmark }: { bookmark: string }) => { + resolveCalls.push(bookmark) + duringResolve?.() + const result = resolvable.get(bookmark) + if (!result || result instanceof Error) throw result ?? new Error(`unknown bookmark ${bookmark}`) + return result } }) }, './icloud': { setStoragePref: (tier: string) => void tiers.push(tier) } }) -const documents = { name: 'Documents', bookmark: 'bookmark-documents' } - -function start(current: typeof documents | null): void { +/** A fresh install state, or one where an earlier version kept `single`. */ +function start(single: { name: string; bookmark: string } | null = null): void { store.clear() - picker.setExternalVaultRef(current) - nextPick = notes + if (single) store.set(SLOT_KEY, JSON.stringify(single)) + nextPick = pickNotes picks = 0 + resolvable.clear() + resolvable.set('bookmark-documents', { url: ICLOUD_DOCUMENTS, name: 'Documents' }) + resolvable.set('bookmark-notes', { url: `${ON_DEVICE}/Notes/`, name: 'Notes' }) + resolvable.set('bookmark-work', { url: `${ON_DEVICE}/Work/`, name: 'Work' }) + resolveCalls.length = 0 + duringResolve = null tiers.length = 0 } -test('Cancel on the question never opens the Files picker and keeps the folder vault', async () => { +function names(): string[] { + return picker.getExternalVaultRefs().map((ref: { name: string }) => ref.name) +} + +function rootOf(name: string): string { + const ref = picker.getExternalVaultRefs().find((r: { name: string }) => r.name === name) + assert.ok(ref, `${name} is not listed`) + return picker.externalVaultRoot(ref.id) +} + +async function pick(next: PickResult): Promise { + nextPick = next + return await picker.pickExternalVault() +} + +test('an install with no folder vault starts with an empty list', () => { + start() + assert.deepEqual(picker.getExternalVaultRefs(), []) + assert.equal(picker.getExternalVaultRef(), null) + assert.equal(picker.currentExternalVaultRoot(), null) +}) + +test('the folder vault an earlier version kept moves into the list with its bookmark bytes', () => { start(documents) - const asked: string[] = [] - const result = await picker.pickExternalVault(async (notice: { body: string }) => { - asked.push(notice.body) - return false - }) - assert.equal(result, null) - assert.equal(picks, 0) - assert.deepEqual(picker.getExternalVaultRef(), documents) - assert.deepEqual(tiers, []) - assert.equal(asked.length, 1) - assert.match(asked[0]!, /removes “Documents” from your vaults list/) + const refs = picker.getExternalVaultRefs() + assert.equal(refs.length, 1) + assert.equal(refs[0].name, 'Documents') + assert.equal(refs[0].bookmark, 'bookmark-documents') + assert.equal(typeof refs[0].id, 'string') + assert.ok(refs[0].id) + // Still the current folder vault, under an identity that holds across reads. + assert.equal(picker.getExternalVaultRef()?.id, refs[0].id) + assert.deepEqual(picker.getExternalVaultRefs(), refs) + assert.equal(picker.currentExternalVaultRoot(), picker.externalVaultRoot(refs[0].id)) }) -test('Choose New Folder opens the picker, and the new folder replaces the old one', async () => { +test('choosing another folder adds it and keeps the folder already listed', async () => { start(documents) - const result = await picker.pickExternalVault(async () => true) - assert.deepEqual(result, { url: 'file:///Notes', name: 'Notes' }) + const result = await pick(pickNotes) + assert.deepEqual(result, { url: `${ON_DEVICE}/Notes/`, name: 'Notes' }) assert.equal(picks, 1) - assert.deepEqual(picker.getExternalVaultRef(), { name: 'Notes', bookmark: 'bookmark-notes' }) + assert.deepEqual(names(), ['Documents', 'Notes']) + assert.equal(picker.getExternalVaultRefs()[0].bookmark, 'bookmark-documents') + assert.equal(picker.getExternalVaultRef()?.name, 'Notes') assert.deepEqual(tiers, ['external']) + // The single slot names the current folder the way an earlier version reads it. + const slot = JSON.parse(store.get(SLOT_KEY)!) + assert.equal(slot.name, 'Notes') + assert.equal(slot.bookmark, 'bookmark-notes') }) -test('the first folder vault is picked without a question', async () => { - start(null) - let asked = 0 - const result = await picker.pickExternalVault(async () => { - asked += 1 - return false - }) - assert.deepEqual(result, { url: 'file:///Notes', name: 'Notes' }) - assert.equal(asked, 0) - assert.equal(picks, 1) +test('a cancelled pick changes nothing', async () => { + start(documents) + const before = picker.getExternalVaultRefs() + assert.equal(await pick({ cancelled: true }), null) + assert.deepEqual(picker.getExternalVaultRefs(), before) + assert.equal(picker.getExternalVaultRef()?.name, 'Documents') + assert.deepEqual(tiers, []) }) -test('the New Vault sheet’s yes is not asked again, and it covers one pick only', async () => { +test('switching opens a listed folder through its own bookmark and makes it current', async () => { start(documents) - nextPick = { cancelled: true } - let asked = 0 - const decline = async (): Promise => { - asked += 1 - return false - } - const release = picker.answerExternalVaultReplace(documents.bookmark) - assert.equal(await picker.pickExternalVault(decline), null) - release() - assert.equal(asked, 0) - assert.equal(picks, 1) - assert.deepEqual(picker.getExternalVaultRef(), documents) + await pick(pickNotes) + resolveCalls.length = 0 + tiers.length = 0 + const opened = await picker.selectExternalVault(rootOf('Documents')) + assert.deepEqual(opened, { url: ICLOUD_DOCUMENTS, name: 'Documents' }) + assert.deepEqual(resolveCalls, ['bookmark-documents']) + assert.equal(picker.getExternalVaultRef()?.name, 'Documents') + assert.equal(picker.currentExternalVaultRoot(), rootOf('Documents')) + assert.deepEqual(tiers, ['external']) + assert.deepEqual(names(), ['Documents', 'Notes']) +}) - assert.equal(await picker.pickExternalVault(decline), null) - assert.equal(asked, 1) - assert.equal(picks, 1) +test('the bare root token from before the list still opens the current folder', async () => { + start(documents) + const opened = await picker.selectExternalVault(picker.EXTERNAL_VAULT_ROOT) + assert.deepEqual(opened, { url: ICLOUD_DOCUMENTS, name: 'Documents' }) + assert.equal(picker.isExternalVaultRoot(picker.EXTERNAL_VAULT_ROOT), true) + assert.equal(picker.isExternalVaultRoot(rootOf('Documents')), true) + assert.equal(picker.isExternalVaultRoot('zn://vaults/My Vault'), false) }) -test('a yes that no pick used is dropped by its release', async () => { +test('a root that names no listed folder opens nothing', async () => { start(documents) - let asked = 0 - picker.answerExternalVaultReplace(documents.bookmark)() - await picker.pickExternalVault(async () => { - asked += 1 - return false + assert.equal(await picker.selectExternalVault(`${picker.EXTERNAL_VAULT_ROOT_PREFIX}gone`), null) + assert.deepEqual(resolveCalls, []) + assert.deepEqual(tiers, []) +}) + +test('a folder iOS cannot reopen stays listed and does not become current', async () => { + start(documents) + await pick(pickNotes) + tiers.length = 0 + resolvable.set('bookmark-documents', new Error('The folder is gone')) + assert.equal(await picker.selectExternalVault(rootOf('Documents')), null) + assert.equal(picker.getExternalVaultRef()?.name, 'Notes') + assert.deepEqual(names(), ['Documents', 'Notes']) + assert.deepEqual(tiers, []) +}) + +test('a folder removed while its bookmark resolves is not reopened or listed again', async () => { + start(documents) + await pick(pickNotes) + const root = rootOf('Documents') + tiers.length = 0 + duringResolve = () => picker.forgetExternalVault(root) + assert.equal(await picker.selectExternalVault(root), null) + assert.deepEqual(names(), ['Notes']) + assert.equal(picker.getExternalVaultRef()?.name, 'Notes') + assert.deepEqual(tiers, []) +}) + +test('removing one folder leaves the others and the current one as they were', async () => { + start(documents) + await pick(pickWork) + await pick(pickNotes) + const before = picker.getExternalVaultRefs() + picker.forgetExternalVault(rootOf('Work')) + assert.deepEqual(names(), ['Documents', 'Notes']) + picker.forgetExternalVault(rootOf('Documents')) + assert.deepEqual(picker.getExternalVaultRefs(), before.filter((r: { name: string }) => r.name === 'Notes')) + assert.equal(picker.getExternalVaultRef()?.name, 'Notes') +}) + +test('removing the current folder clears the pointer and it does not come back', async () => { + start(documents) + await pick(pickNotes) + picker.forgetExternalVault(rootOf('Notes')) + assert.equal(picker.getExternalVaultRef(), null) + assert.equal(store.has(SLOT_KEY), false) + assert.deepEqual(names(), ['Documents']) +}) + +test('boot reopens the current folder and a refreshed stale bookmark is kept', async () => { + start(documents) + const root = picker.currentExternalVaultRoot() + resolvable.set('bookmark-documents', { + url: ICLOUD_DOCUMENTS, + name: 'Documents', + bookmark: 'bookmark-documents-fresh' }) - assert.equal(asked, 1) - assert.equal(picks, 0) + assert.deepEqual(await picker.resolveExternalVault(), { url: ICLOUD_DOCUMENTS, name: 'Documents' }) + assert.equal(picker.getExternalVaultRef()?.bookmark, 'bookmark-documents-fresh') + assert.equal(picker.currentExternalVaultRoot(), root) + assert.equal(JSON.parse(store.get(SLOT_KEY)!).bookmark, 'bookmark-documents-fresh') +}) + +test('a folder renamed in Files keeps its place under its new name', async () => { + start(documents) + const root = picker.currentExternalVaultRoot() + resolvable.set('bookmark-documents', { url: `${ON_DEVICE}/Papers/`, name: 'Papers' }) + assert.deepEqual(await picker.resolveExternalVault(), { url: `${ON_DEVICE}/Papers/`, name: 'Papers' }) + assert.deepEqual(names(), ['Papers']) + assert.equal(picker.currentExternalVaultRoot(), root) }) -test('a yes about another folder does not cover the one that is set', async () => { +test('choosing a listed folder again refreshes its entry instead of listing it twice', async () => { start(documents) - let asked = 0 - const release = picker.answerExternalVaultReplace('bookmark-elsewhere') - await picker.pickExternalVault(async () => { - asked += 1 - return false + await picker.resolveExternalVault() + await pick(pickNotes) + const id = picker.getExternalVaultRefs()[0].id + // The same folder, spelled without /private and without the trailing slash. + await pick({ + cancelled: false, + url: 'file:///var/mobile/Library/Mobile%20Documents/com~apple~CloudDocs/Documents', + name: 'Documents', + bookmark: 'bookmark-documents-2' }) - release() - assert.equal(asked, 1) - assert.equal(picks, 0) + assert.deepEqual(names(), ['Documents', 'Notes']) + const refs = picker.getExternalVaultRefs() + assert.equal(refs[0].id, id) + assert.equal(refs[0].bookmark, 'bookmark-documents-2') + assert.equal(picker.getExternalVaultRef()?.id, id) +}) + +test('a carried-over folder is recognized by resolving its bookmark when chosen again', async () => { + start(documents) + await pick({ cancelled: false, url: ICLOUD_DOCUMENTS, name: 'Documents', bookmark: 'bookmark-documents-2' }) + assert.deepEqual(resolveCalls, ['bookmark-documents']) + assert.deepEqual(names(), ['Documents']) + assert.equal(picker.getExternalVaultRefs()[0].bookmark, 'bookmark-documents-2') +}) + +test('two folders with the same name are two vaults', async () => { + start(documents) + await pick({ cancelled: false, url: `${ON_DEVICE}/Documents/`, name: 'Documents', bookmark: 'bookmark-local' }) + assert.deepEqual(names(), ['Documents', 'Documents']) + const [first, second] = picker.getExternalVaultRefs() + assert.notEqual(first.id, second.id) + assert.equal(first.bookmark, 'bookmark-documents') + assert.equal(picker.currentExternalVaultRoot(), picker.externalVaultRoot(second.id)) +}) + +test('a folder an earlier version wrote to the single slot joins the list', () => { + start(documents) + picker.getExternalVaultRefs() + // After a downgrade, an earlier version picked Work and kept only the slot. + store.set(SLOT_KEY, JSON.stringify({ name: 'Work', bookmark: 'bookmark-work' })) + assert.equal(picker.getExternalVaultRef()?.name, 'Work') + assert.deepEqual(names(), ['Documents', 'Work']) + assert.equal(picker.getExternalVaultRef()?.id, picker.getExternalVaultRefs()[1].id) }) diff --git a/src/bridge/folder-picker.ts b/src/bridge/folder-picker.ts index 474034e..e02dd60 100644 --- a/src/bridge/folder-picker.ts +++ b/src/bridge/folder-picker.ts @@ -2,13 +2,26 @@ * External-folder vault tier (spec 03 "advanced" tier): the user picks any * Files-app folder (iCloud Drive, On My iPhone, Working Copy, ...) via the * native document picker; a security-scoped bookmark keeps it accessible - * across launches. Only one bookmark is kept, so a pick replaces the folder - * vault that was there, and the person is asked first - * (folder-vault-replace.ts). + * across launches. + * + * Every picked folder stays in the Vaults sheet until the person removes it, + * as on Android (zennotes#584). Nothing native ever limited this to one: + * FolderPickerPlugin resolves whichever bookmark it is handed and + * CloudFilesPlugin admits every root activated this session. Only the + * single localStorage slot did, and a new pick overwrote the folder in it. + * That slot is still written, as the pointer to the current folder vault + * (an earlier build reads it as its one folder), and on the first read + * after the update its folder becomes the list's first entry with its + * bookmark bytes as they were. + * + * A folder is known by a random id, not by its bookmark: iOS hands back new + * bookmark bytes on every pick and when a stored one goes stale, and the + * root token the Vaults sheet switches by must not change with them. A pick + * of a folder that is already listed is recognized by where it resolves. */ import { registerPlugin } from '@capacitor/core' import { setStoragePref } from './icloud' -import { pickAfterReplaceNotice, type FolderVaultReplaceNotice } from './folder-vault-replace' +import { randomUUID } from './uuid' interface FolderPickerPlugin { pickFolder(): Promise<{ @@ -24,73 +37,230 @@ interface FolderPickerPlugin { export const FolderPicker = registerPlugin('FolderPicker') -const EXTERNAL_KEY = 'zn-mobile:external-vault' +const CURRENT_KEY = 'zn-mobile:external-vault' // the current folder vault (the old single slot) +const LIST_KEY = 'zn-mobile:external-vaults' // every folder vault in the Vaults sheet + +/** Root tokens the Vaults sheet hands to openLocalVault. The bare token is + * from before the list and means the current folder vault. */ +export const EXTERNAL_VAULT_ROOT = 'zn://external-vault' +export const EXTERNAL_VAULT_ROOT_PREFIX = 'zn://external-vaults/' export interface ExternalVaultRef { + id: string name: string + /** Base64 bookmark data, exactly as FolderPickerPlugin returned it. */ bookmark: string + /** file:// URL the bookmark last resolved to. A folder carried over from + * the single slot has none until it is next opened. */ + url?: string } -export function getExternalVaultRef(): ExternalVaultRef | null { +type ResolvedRef = ExternalVaultRef & { url: string } + +export function externalVaultRoot(id: string): string { + return `${EXTERNAL_VAULT_ROOT_PREFIX}${encodeURIComponent(id)}` +} + +export function isExternalVaultRoot(root: string): boolean { + return root === EXTERNAL_VAULT_ROOT || root.startsWith(EXTERNAL_VAULT_ROOT_PREFIX) +} + +function readJson(key: string): unknown { try { - const raw = localStorage.getItem(EXTERNAL_KEY) - if (!raw) return null - const parsed = JSON.parse(raw) as ExternalVaultRef - return parsed.bookmark ? parsed : null + const raw = localStorage.getItem(key) + return raw === null ? null : JSON.parse(raw) } catch { return null } } -export function setExternalVaultRef(ref: ExternalVaultRef | null): void { - if (ref) localStorage.setItem(EXTERNAL_KEY, JSON.stringify(ref)) - else localStorage.removeItem(EXTERNAL_KEY) +/** A stored entry, which an earlier build wrote without an id or a url. */ +function readRef(value: unknown): (Omit & { id?: string }) | null { + if (!value || typeof value !== 'object') return null + const { id, name, bookmark, url } = value as Record + if (typeof bookmark !== 'string' || !bookmark) return null + return { + ...(typeof id === 'string' && id ? { id } : {}), + name: typeof name === 'string' && name ? name : 'Vault', + bookmark, + ...(typeof url === 'string' && url ? { url } : {}) + } +} + +function withId(ref: Omit & { id?: string }): ExternalVaultRef { + return { ...ref, id: ref.id ?? randomUUID() } } -// The New Vault sheet asks in place, before the store starts a vault change, -// so its Cancel leaves the workspace exactly as it was. Its yes still has to -// cross the store to reach the picker (pickLocalVault takes no arguments), -// and it covers one pick of the folder it named; anything else asks here. -let replaceAnsweredFor: string | null = null +function saveRefs(refs: ExternalVaultRef[]): void { + localStorage.setItem(LIST_KEY, JSON.stringify(refs)) +} + +function writeCurrent(ref: ExternalVaultRef | null): void { + if (ref) localStorage.setItem(CURRENT_KEY, JSON.stringify(ref)) + else localStorage.removeItem(CURRENT_KEY) +} -/** Records the New Vault sheet's yes for the folder vault it named. The - * returned release drops an answer no pick used (a refused vault change). */ -export function answerExternalVaultReplace(bookmark: string): () => void { - replaceAnsweredFor = bookmark - return () => { - if (replaceAnsweredFor === bookmark) replaceAnsweredFor = null +/** Every folder vault, oldest first. */ +export function getExternalVaultRefs(): ExternalVaultRef[] { + const stored = readJson(LIST_KEY) + if (Array.isArray(stored)) { + const refs = stored.map(readRef).filter((ref) => ref !== null) + if (refs.every((ref) => ref.id)) return refs as ExternalVaultRef[] + const named = refs.map(withId) + saveRefs(named) + return named } + // First read after the update: the single slot's folder starts the list. + const single = readRef(readJson(CURRENT_KEY)) + const seeded = single ? [withId(single)] : [] + saveRefs(seeded) + if (seeded[0]) writeCurrent(seeded[0]) + return seeded } -/** Present the picker; on selection persist the bookmark + flip storage. - * With a folder vault already set, `confirm` is asked before the picker - * opens, and a no leaves the bookmark and the storage tier untouched. */ -export async function pickExternalVault( - confirm: (notice: FolderVaultReplaceNotice) => Promise -): Promise<{ url: string; name: string } | null> { - const current = getExternalVaultRef() - const answered = current !== null && current.bookmark === replaceAnsweredFor - replaceAnsweredFor = null - return await pickAfterReplaceNotice(answered ? null : current, confirm, async () => { - const result = await FolderPicker.pickFolder() - if (result.cancelled || !result.url || !result.bookmark) return null - setExternalVaultRef({ name: result.name ?? 'Vault', bookmark: result.bookmark }) - setStoragePref('external') - return { url: result.url, name: result.name ?? 'Vault' } - }) -} - -/** Re-open the bookmarked folder at boot (refreshing a stale bookmark). */ -export async function resolveExternalVault(): Promise<{ url: string; name: string } | null> { +/** The folder vault that is open, or was open last: boot reopens this one. + * A slot an earlier build wrote can name a folder the list has never seen, + * and that folder joins the list rather than being dropped. */ +export function getExternalVaultRef(): ExternalVaultRef | null { + const refs = getExternalVaultRefs() + const current = readRef(readJson(CURRENT_KEY)) + if (!current) return null + const known = refs.find((ref) => + current.id ? ref.id === current.id : ref.bookmark === current.bookmark + ) + if (known) return known + const adopted = withId(current) + saveRefs([...refs, adopted]) + writeCurrent(adopted) + return adopted +} + +/** Root token of the current folder vault: two listed folders can share a + * name, so the Vaults sheet tells the open one by this. */ +export function currentExternalVaultRoot(): string | null { const ref = getExternalVaultRef() - if (!ref) return null + return ref ? externalVaultRoot(ref.id) : null +} + +function idFromRoot(root: string): string | null { + if (root === EXTERNAL_VAULT_ROOT) return getExternalVaultRef()?.id ?? null + if (!root.startsWith(EXTERNAL_VAULT_ROOT_PREFIX)) return null + try { + return decodeURIComponent(root.slice(EXTERNAL_VAULT_ROOT_PREFIX.length)) + } catch { + return null + } +} + +/** Writes `ref` over the entry with its id, or appends it when `add`. + * False when there was no entry to write over and `add` is off. */ +function saveRef(ref: ExternalVaultRef, add: boolean): boolean { + const refs = getExternalVaultRefs() + const at = refs.findIndex((entry) => entry.id === ref.id) + if (at >= 0) refs[at] = ref + else if (add) refs.push(ref) + else return false + saveRefs(refs) + if (readRef(readJson(CURRENT_KEY))?.id === ref.id) writeCurrent(ref) + return true +} + +/** Resolves a listed folder's bookmark, which also opens its security scope + * for this session, and keeps what iOS reports: the folder's current name + * and place, and fresh bookmark bytes when the stored ones went stale. Null + * when iOS cannot resolve it, or when the folder left the list meanwhile + * (a removed folder must not come back as the current one). */ +async function resolveRef(ref: ExternalVaultRef): Promise { try { const resolved = await FolderPicker.resolveBookmark({ bookmark: ref.bookmark }) - if (resolved.bookmark) { - setExternalVaultRef({ name: resolved.name, bookmark: resolved.bookmark }) + const fresh: ResolvedRef = { + ...ref, + name: resolved.name || ref.name, + bookmark: resolved.bookmark ?? ref.bookmark, + url: resolved.url } - return { url: resolved.url, name: resolved.name } + return saveRef(fresh, false) ? fresh : null } catch { return null } } + +/** One folder as a path: a picked URL and a resolved bookmark's URL may spell + * it with different percent-encoding, a trailing slash or not, and /var + * with or without the /private it links to. */ +function folderPath(url: string): string { + let path = url.replace(/^file:\/\//, '') + try { + path = decodeURIComponent(path) + } catch { + // Not percent-encoded after all: compare it as it is. + } + return path.replace(/\/+$/, '').replace(/^\/private(?=\/var\/)/, '') +} + +/** The listed folder a pick landed on, if any. Only a folder carried over + * from the single slot lacks a url, and only one with the picked name is + * resolved to learn where it is. */ +async function listedFolderAt(url: string, name: string): Promise { + const target = folderPath(url) + const refs = getExternalVaultRefs() + const known = refs.find((ref) => ref.url !== undefined && folderPath(ref.url) === target) + if (known) return known + for (const ref of refs) { + if (ref.url !== undefined || ref.name !== name) continue + const resolved = await resolveRef(ref) + if (resolved && folderPath(resolved.url) === target) return resolved + } + return null +} + +/** Present the picker; the chosen folder joins the list (or refreshes its + * entry when it is already listed) and becomes the current vault. */ +export async function pickExternalVault(): Promise<{ url: string; name: string } | null> { + const result = await FolderPicker.pickFolder() + if (result.cancelled || !result.url || !result.bookmark) return null + const name = result.name ?? 'Vault' + const listed = await listedFolderAt(result.url, name) + const ref: ExternalVaultRef = { + id: listed?.id ?? randomUUID(), + name, + bookmark: result.bookmark, + url: result.url + } + saveRef(ref, true) + writeCurrent(ref) + setStoragePref('external') + return { url: result.url, name } +} + +/** Re-open the current folder vault at boot (refreshing a stale bookmark). */ +export async function resolveExternalVault(): Promise<{ url: string; name: string } | null> { + const ref = getExternalVaultRef() + const resolved = ref ? await resolveRef(ref) : null + return resolved ? { url: resolved.url, name: resolved.name } : null +} + +/** Resolves the listed folder a root token names and makes it the current + * vault. Null, with nothing changed, when the token names no listed folder + * or iOS cannot reopen it (the entry stays, to retry or remove). */ +export async function selectExternalVault( + root: string +): Promise<{ url: string; name: string } | null> { + const id = idFromRoot(root) + const ref = id === null ? undefined : getExternalVaultRefs().find((entry) => entry.id === id) + const resolved = ref ? await resolveRef(ref) : null + if (!resolved) return null + writeCurrent(resolved) + setStoragePref('external') + return { url: resolved.url, name: resolved.name } +} + +/** Drops the folder a root token names from the list, without touching its + * files; the other folders and their bookmarks stay as they are. */ +export function forgetExternalVault(root: string): void { + const id = idFromRoot(root) + if (id === null) return + const current = getExternalVaultRef() + saveRefs(getExternalVaultRefs().filter((ref) => ref.id !== id)) + if (current?.id === id) writeCurrent(null) +} diff --git a/src/bridge/folder-vault-replace.test.ts b/src/bridge/folder-vault-replace.test.ts deleted file mode 100644 index 46f2827..0000000 --- a/src/bridge/folder-vault-replace.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { - FOLDER_VAULT_HINT, - folderVaultReplaceNotice, - pickAfterReplaceNotice -} from './folder-vault-replace.ts' - -test('with no Files folder vault set a pick replaces nothing, so nothing is asked', () => { - assert.equal(folderVaultReplaceNotice(null), null) -}) - -test('the question names the folder vault, what stays in Files, and the way back', () => { - assert.deepEqual(folderVaultReplaceNotice({ name: 'Documents' }), { - title: 'Replace your Files folder vault?', - body: - 'ZenNotes keeps one Files folder vault at a time. Choosing a new folder removes “Documents” ' + - 'from your vaults list. Its notes stay in Files, and you can choose that folder again anytime.', - confirmLabel: 'Choose New Folder', - cancelLabel: 'Cancel' - }) - assert.equal(FOLDER_VAULT_HINT, 'One Files folder at a time') -}) - -test('the copy carries no em dash (house style)', () => { - const notice = folderVaultReplaceNotice({ name: 'Documents' }) - assert.ok(notice) - for (const text of [...Object.values(notice), FOLDER_VAULT_HINT]) { - assert.equal(text.includes(String.fromCodePoint(0x2014)), false) - } -}) - -test('Cancel never opens the picker', async () => { - const asked: string[] = [] - let picks = 0 - const result = await pickAfterReplaceNotice( - { name: 'Documents' }, - async (notice) => { - asked.push(notice.title) - return false - }, - async () => { - picks += 1 - return 'Notes' - } - ) - assert.equal(result, null) - assert.equal(picks, 0) - assert.deepEqual(asked, ['Replace your Files folder vault?']) -}) - -test('Choose New Folder opens the picker once and hands back what was picked', async () => { - let picks = 0 - const result = await pickAfterReplaceNotice( - { name: 'Documents' }, - async () => true, - async () => { - picks += 1 - return 'Notes' - } - ) - assert.equal(result, 'Notes') - assert.equal(picks, 1) -}) - -test('the first folder vault opens the picker without a question', async () => { - let asked = 0 - let picks = 0 - const result = await pickAfterReplaceNotice( - null, - async () => { - asked += 1 - return false - }, - async () => { - picks += 1 - return 'Documents' - } - ) - assert.equal(result, 'Documents') - assert.equal(asked, 0) - assert.equal(picks, 1) -}) diff --git a/src/bridge/folder-vault-replace.ts b/src/bridge/folder-vault-replace.ts deleted file mode 100644 index afa15c4..0000000 --- a/src/bridge/folder-vault-replace.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * One Files folder vault at a time: folder-picker.ts keeps a single - * security-scoped bookmark, so choosing another folder replaces the current - * one, and the replaced folder drops out of the Vaults sheet while its notes - * stay in Files. The picker used to do that without a word, and a vault the - * person had been using simply vanished from the list. Every path to the - * picker asks first with this copy, and the Vaults sheet states the rule - * under Folders. - * - * No Capacitor imports, so the copy and the ask-then-pick order load in - * node:test as they are. - */ - -export interface FolderVaultReplaceNotice { - title: string - body: string - confirmLabel: string - cancelLabel: string -} - -/** The rule, stated once under the Vaults sheet's Folders section. */ -export const FOLDER_VAULT_HINT = 'One Files folder at a time' - -/** What to ask before the picker opens, or null when no folder vault is set - * and a pick replaces nothing. Names the folder as the Vaults sheet lists it. */ -export function folderVaultReplaceNotice( - current: { name: string } | null -): FolderVaultReplaceNotice | null { - if (!current) return null - return { - title: 'Replace your Files folder vault?', - body: - `ZenNotes keeps one Files folder vault at a time. Choosing a new folder removes “${current.name}” ` + - 'from your vaults list. Its notes stay in Files, and you can choose that folder again anytime.', - confirmLabel: 'Choose New Folder', - cancelLabel: 'Cancel' - } -} - -/** Asks when there is a folder vault to replace, then picks. A declined - * notice resolves null and never opens the picker. */ -export async function pickAfterReplaceNotice( - current: { name: string } | null, - confirm: (notice: FolderVaultReplaceNotice) => Promise, - pick: () => Promise -): Promise { - const notice = folderVaultReplaceNotice(current) - if (notice && !(await confirm(notice))) return null - return await pick() -} diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 661daf9..3f6eebe 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -1,5 +1,4 @@ import { relocateLocalVault } from '@zennotes/app-core/workspace' -import { confirm as confirmApp } from '@zennotes/app-core/dialogs' /** * The mobile `window.zen` — third ZenBridge implementation (after Electron IPC * and the web HTTP bridge). Vault operations run against the on-device vault @@ -71,10 +70,14 @@ import { localVaultPath } from './icloud' import { - getExternalVaultRef, - setExternalVaultRef, + currentExternalVaultRoot, + externalVaultRoot, + forgetExternalVault, + getExternalVaultRefs, + isExternalVaultRoot, pickExternalVault, - resolveExternalVault + resolveExternalVault, + selectExternalVault } from './folder-picker' import { emitVaultChange, onVaultChange, onOpenNoteRequested, requestOpenNote } from './events' import { openAssetExternally } from './open-asset' @@ -163,9 +166,9 @@ export const VAULT_ROOT_PREFIX = 'zn://vaults/' // entry point can route a switch to either storage tier (the vault switcher // sheet passes these tokens through the store's openLocalVault action). export const ICLOUD_VAULT_ROOT_PREFIX = 'zn://icloud-vaults/' -// The one bookmarked Files-app folder (external tier) — a fixed token, since -// only a single security-scoped bookmark is kept at a time. -export const EXTERNAL_VAULT_ROOT = 'zn://external-vault' +// Files-app folder vaults (external tier) carry their own root tokens, one +// per listed folder; folder-picker.ts owns them. +export { currentExternalVaultRoot, forgetExternalVault } export interface MobileVaultEntry { root: string @@ -240,8 +243,9 @@ export async function listSwitchableVaults(): Promise { }) } } - const external = getExternalVaultRef() - if (external) out.push({ root: EXTERNAL_VAULT_ROOT, name: external.name, tier: 'external' }) + for (const ref of getExternalVaultRefs()) { + out.push({ root: externalVaultRoot(ref.id), name: ref.name, tier: 'external' }) + } return out } @@ -256,7 +260,9 @@ export async function listSwitchableVaults(): Promise { export function isCurrentVaultEntry(entry: MobileVaultEntry): boolean { if (activeRemote()) return false if (!vault || vault.name !== entry.name) return false - return getStoragePref() === entry.tier + if (getStoragePref() !== entry.tier) return false + // Two listed folders can share a name: the root token decides. + return entry.tier !== 'external' || entry.root === currentExternalVaultRoot() } async function icloudVaultUrl(name: string): Promise { @@ -303,7 +309,7 @@ export async function renameVault(entry: MobileVaultEntry, newName: string): Pro export async function deleteVault(entry: MobileVaultEntry): Promise { if (isCurrentVaultEntry(entry)) throw new Error('Switch to another vault first.') if (entry.tier === 'external') { - setExternalVaultRef(null) + forgetExternalVault(entry.root) return } if (entry.tier === 'icloud') { @@ -317,11 +323,6 @@ export async function deleteVault(entry: MobileVaultEntry): Promise { } } -/** Forget the Files-app folder bookmark without touching its contents. */ -export function forgetExternalVault(): void { - setExternalVaultRef(null) -} - /** Move a vault between the on-device and iCloud tiers (setUbiquitous under * the hood, so notes transfer — not copy). Reopens it when it's current. */ export async function moveVault(entry: MobileVaultEntry, to: 'local' | 'icloud'): Promise { @@ -996,15 +997,15 @@ export const mobileBridge: ZenBridge = { openLocalVault: async (root: string) => { // One entry point for switching to any device-reachable vault: local // roots return to local storage mode, zn://icloud-vaults/ roots open the - // named vault in the iCloud container, and the external token reopens - // the bookmarked Files-app folder. Either way leaves remote mode. + // named vault in the iCloud container, and an external token reopens + // the Files-app folder it names through that folder's own bookmark. + // Either way leaves remote mode. await disconnectRemote() - if (root === EXTERNAL_VAULT_ROOT) { - const external = await resolveExternalVault() + if (isExternalVaultRoot(root)) { + const external = await selectExternalVault(root) if (!external) { throw new Error('That folder could not be opened. Pick it again with Choose Folder.') } - setStoragePref('external') return await openVaultByName(external.name, external.url) } if (root.startsWith(ICLOUD_VAULT_ROOT_PREFIX)) { @@ -1021,18 +1022,7 @@ export const mobileBridge: ZenBridge = { }, closeVault: () => describeCurrentVault(), pickVault: async () => { - // The command palette's Open Vault… and Settings' Change… (iPad) reach - // the picker only through here, so this is where they ask, with the - // core's confirm dialog (a bottom sheet on the phone). The New Vault - // sheet has already asked in place and is not asked twice. - const picked = await pickExternalVault((notice) => - confirmApp({ - title: notice.title, - description: notice.body, - confirmLabel: notice.confirmLabel, - cancelLabel: notice.cancelLabel - }) - ) + const picked = await pickExternalVault() if (!picked) return null await disconnectRemote() return await openVaultByName(picked.name, picked.url) diff --git a/src/bridge/note-sidecars.test.ts b/src/bridge/note-sidecars.test.ts new file mode 100644 index 0000000..301275a --- /dev/null +++ b/src/bridge/note-sidecars.test.ts @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +// A note's two app-owned sidecars, at the paths desktop writes them +// (apps/desktop/src/main/note-sidecars.ts and note-creation-metadata.ts). A +// rename or move that leaves either behind loses the comments and the +// creation date on every device, and Cloud sync carries the orphan around. +const comments = (rel: string): string => `.zennotes/comments/${rel}.comments.json` +const created = (rel: string): string => `.zennotes/note-metadata/${rel}.metadata.json` + +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: () => null } }) +const { MobileVault, DEFAULT_VAULT_SETTINGS } = await loadMobileModule([ + './src/bridge/vault-fs', './src/bridge/native-fs', '@zennotes/bridge-contract/ipc' +]) + +/** The NativeFs surface MobileVault uses. Like the providers, a rename never + * overwrites and a delete of a missing file fails. */ +class MemoryFs { + files = new Map() + directories = new Set() + failMove: ((from: string, to: string) => boolean) | null = null + async statVerified(path: string): Promise<'file' | 'directory' | null> { + if (this.files.has(path)) return 'file' + const inside = (key: string): boolean => key.startsWith(`${path}/`) + return this.directories.has(path) || [...this.files.keys()].some(inside) ? 'directory' : null + } + async exists(path: string): Promise { return await this.statVerified(path) !== null } + async mkdir(path: string): Promise { this.directories.add(path) } + async readText(path: string): Promise { + const body = this.files.get(path) + if (body === undefined) throw new Error(`File does not exist: ${path}`) + return body + } + async readTextOrNull(path: string): Promise { return this.files.get(path) ?? null } + async writeText(path: string, body: string): Promise { this.files.set(path, body) } + async deleteFile(path: string): Promise { + if (!this.files.delete(path)) throw new Error(`File does not exist: ${path}`) + } + async rmdir(path: string): Promise { + for (const key of [...this.files.keys()]) if (key === path || key.startsWith(`${path}/`)) this.files.delete(key) + for (const key of [...this.directories]) if (key === path || key.startsWith(`${path}/`)) this.directories.delete(key) + } + async rename(from: string, to: string): Promise { + if (this.failMove?.(from, to)) throw new Error('Provider refused move') + assert.ok(await this.exists(from), `nothing to move at ${from}`) + assert.equal(await this.exists(to), false, `a move must never overwrite ${to}`) + const moved = (key: string): boolean => key === from || key.startsWith(`${from}/`) + for (const [key, body] of [...this.files]) { + if (!moved(key)) continue + this.files.delete(key) + this.files.set(to + key.slice(from.length), body) + } + for (const key of [...this.directories]) { + if (!moved(key)) continue + this.directories.delete(key) + this.directories.add(to + key.slice(from.length)) + } + } +} + +const NOTE_BODY = '# One\n\nExact café 日本語.\n' +const COMMENTS_BODY = '{"version":1,"comments":[{"id":"c1","body":"keep me"}]}' +const CREATED_BODY = '{"version":1,"createdAt":1726000000000}\n' + +/** One note with both sidecars, in a vault whose system folders are remapped + * (inbox `Notes`, archive `Old`, trash `Bin`), or with the inbox at the root. */ +function fixture(rootMode = false) { + const fs = new MemoryFs() + const vault = new MobileVault('Fixture') + Object.assign(vault, { + fs, + settingsCache: { + ...structuredClone(DEFAULT_VAULT_SETTINGS), + primaryNotesLocation: rootMode ? 'root' : 'inbox', + systemFolderPaths: { inbox: 'Notes', archive: 'Old', trash: 'Bin', quick: 'Capture' } + }, + listNotes: async () => [], + invalidateMeta: () => {}, + metaForPath: async (path: string) => ({ path, title: path.split('/').pop() }) + }) + const primary = rootMode ? '' : 'Notes/' + const note = `${primary}Work/One.md` + fs.files.set(note, NOTE_BODY) + fs.files.set(comments(note), COMMENTS_BODY) + fs.files.set(created(note), CREATED_BODY) + return { fs, vault, primary, note, original: new Map(fs.files) } +} + +/** The note and both sidecars sit at `to`, byte for byte, and nothing at `from`. */ +function assertCarried(fs: MemoryFs, from: string, to: string): void { + assert.equal(fs.files.get(to), NOTE_BODY) + assert.equal(fs.files.get(comments(to)), COMMENTS_BODY) + assert.equal(fs.files.get(created(to)), CREATED_BODY) + for (const left of [from, comments(from), created(from)]) { + assert.equal(fs.files.has(left), false, `${left} was left behind`) + } +} + +for (const rootMode of [false, true]) { + test(`renaming a note carries its comments and creation date (root=${rootMode})`, async () => { + const s = fixture(rootMode) + const result = await s.vault.renameNote(s.note, 'Two') + assert.equal(result.path, `${s.primary}Work/Two.md`) + assertCarried(s.fs, s.note, result.path) + }) + + test(`archive, trash and restore carry the creation date through remapped folders (root=${rootMode})`, async () => { + const s = fixture(rootMode) + const archived = await s.vault.archiveNote(s.note) + assert.equal(archived.path, 'Old/Work/One.md') + assertCarried(s.fs, s.note, archived.path) + const unarchived = await s.vault.unarchiveNote(archived.path) + assert.equal(unarchived.path, s.note) + const trashed = await s.vault.moveToTrash(s.note) + assert.equal(trashed.path, 'Bin/Work/One.md') + assertCarried(s.fs, s.note, trashed.path) + const restored = await s.vault.restoreFromTrash(trashed.path) + assert.equal(restored.path, s.note) + assert.deepEqual(s.fs.files, s.original) + }) +} + +test('moving a note to another folder carries its creation date', async () => { + const s = fixture() + const result = await s.vault.moveNote(s.note, 'inbox', 'Elsewhere/Deeper') + assert.equal(result.path, 'Notes/Elsewhere/Deeper/One.md') + assertCarried(s.fs, s.note, result.path) +}) + +test('renaming a folder carries the creation dates of every note inside it', async () => { + const s = fixture() + s.fs.files.set('Notes/Work/Deep/Two.md', '# Two\n') + s.fs.files.set(created('Notes/Work/Deep/Two.md'), '{"version":1,"createdAt":1}\n') + await s.vault.renameFolder('inbox', 'Work', 'Moved') + assertCarried(s.fs, s.note, 'Notes/Moved/One.md') + assert.equal(s.fs.files.get(created('Notes/Moved/Deep/Two.md')), '{"version":1,"createdAt":1}\n') + assert.equal([...s.fs.files.keys()].some(key => key.includes('/Work/')), false) +}) + +test('a moved note with no sidecars moves alone and gains none', async () => { + const s = fixture() + s.fs.files.delete(comments(s.note)) + s.fs.files.delete(created(s.note)) + await s.vault.renameNote(s.note, 'Two') + assert.deepEqual([...s.fs.files.keys()], ['Notes/Work/Two.md']) +}) + +// ZenNotes #839: a creation date with no note beside it belongs to nobody +// (its note was moved or deleted outside ZenNotes). Desktop discards it, the +// way creating a note does, instead of handing it to the next note there. +test('a creation date left at the destination by a vanished note is discarded, not inherited', async () => { + const s = fixture() + s.fs.files.set(created('Notes/Work/Two.md'), '{"version":1,"createdAt":5}\n') + await s.vault.renameNote(s.note, 'Two') + assertCarried(s.fs, s.note, 'Notes/Work/Two.md') + + const bare = fixture() + bare.fs.files.delete(created(bare.note)) + bare.fs.files.set(created('Notes/Elsewhere/One.md'), '{"version":1,"createdAt":5}\n') + await bare.vault.moveNote(bare.note, 'inbox', 'Elsewhere') + assert.equal(bare.fs.files.has(created('Notes/Elsewhere/One.md')), false) + assert.equal(bare.fs.files.get(comments('Notes/Elsewhere/One.md')), COMMENTS_BODY) +}) + +// Taking over another note's discussion and deleting it are both wrong, so +// leftover comments refuse the move, in desktop's words, naming the file. +test('leftover comments at the destination refuse a rename or move and change nothing', async () => { + const s = fixture() + s.fs.files.set(comments('Notes/Work/Two.md'), 'an earlier discussion') + s.fs.files.set(comments('Notes/Elsewhere/One.md'), 'another discussion') + const before = new Map(s.fs.files) + await assert.rejects(s.vault.renameNote(s.note, 'Two'), { + message: 'Comments from an earlier note named “Two” are still in .zennotes/comments/Notes/Work/Two.md.comments.json. Move or delete that file to use this name.' + }) + await assert.rejects(s.vault.moveNote(s.note, 'inbox', 'Elsewhere'), { + message: 'Comments from an earlier note named “One” are still in .zennotes/comments/Notes/Elsewhere/One.md.comments.json. Move or delete that file to use this name.' + }) + assert.deepEqual(s.fs.files, before) +}) + +test('a creation date that cannot move puts the note and its comments back', async () => { + const s = fixture() + s.fs.failMove = from => from.startsWith('.zennotes/note-metadata/') + await assert.rejects(s.vault.renameNote(s.note, 'Two'), /Provider refused move/) + assert.deepEqual(s.fs.files, s.original) + await assert.rejects(s.vault.renameFolder('inbox', 'Work', 'Moved'), /Provider refused move/) + assert.deepEqual(s.fs.files, s.original) +}) + +// Desktop's relocateFolderTrees refuses any existing destination tree. +test('renaming a folder onto a leftover creation-date tree refuses and changes nothing', async () => { + const s = fixture() + s.fs.files.set(created('Notes/Moved/Ghost.md'), '{"version":1,"createdAt":5}\n') + const before = new Map(s.fs.files) + await assert.rejects( + s.vault.renameFolder('inbox', 'Work', 'Moved'), + /Destination already exists: \.zennotes\/note-metadata\/Notes\/Moved$/ + ) + assert.deepEqual(s.fs.files, before) +}) + +// Deleting takes the creation date along, as on desktop. Otherwise the phone +// would plant the leftover trees that refuse a later folder rename. +test('deleting a note, a folder or the trash leaves no creation date behind', async () => { + const note = fixture() + await note.vault.deleteNote(note.note) + assert.equal(note.fs.files.size, 0) + + const folder = fixture() + await folder.vault.deleteFolder('inbox', 'Work') + assert.equal(folder.fs.files.size, 0) + + const trash = fixture() + await trash.vault.moveToTrash(trash.note) + await trash.vault.emptyTrash() + assert.equal(trash.fs.files.size, 0) +}) + +test('a folder deleted on the phone does not block renaming another folder onto its name', async () => { + const s = fixture() + s.fs.files.set('Notes/Gone/Old.md', '# Old\n') + s.fs.files.set(created('Notes/Gone/Old.md'), '{"version":1,"createdAt":5}\n') + await s.vault.deleteFolder('inbox', 'Gone') + await s.vault.renameFolder('inbox', 'Work', 'Gone') + assertCarried(s.fs, s.note, 'Notes/Gone/One.md') + assert.equal(s.fs.files.has(created('Notes/Gone/Old.md')), false) +}) diff --git a/src/bridge/vault-core.ts b/src/bridge/vault-core.ts index 9e736ed..2e0b40e 100644 --- a/src/bridge/vault-core.ts +++ b/src/bridge/vault-core.ts @@ -7,6 +7,7 @@ * spelling — do not "fix" it and do not add an `attachments` variant. */ import type { ImportedAssetKind, NoteFolder, VaultSettings } from '@zennotes/bridge-contract/ipc' +import { noteCommentsSidecarPath } from '@zennotes/shared-domain/note-comments' import { resolveFolderPath, systemFolderForDirName, @@ -31,8 +32,13 @@ export const ATTACHMENTS_DIRS = [ASSETS_DIR, ...LEGACY_ATTACHMENTS_DIRS] export const INTERNAL_VAULT_DIR = '.zennotes' export const DELETED_ASSETS_DIR = 'deleted-assets' export const VAULT_SETTINGS_FILE = 'vault.json' -export const NOTE_COMMENTS_DIR = 'comments' -export const NOTE_COMMENTS_SUFFIX = '.comments.json' +export { NOTE_COMMENTS_DIR, NOTE_COMMENTS_SUFFIX } from '@zennotes/shared-domain/note-comments' +// A note's creation date, which desktop keeps beside it as +// `.zennotes/note-metadata/.metadata.json` +// (apps/desktop/src/main/note-creation-metadata.ts, no shared export yet). +// The phone never reads or writes one, only moves or deletes it with its note. +export const NOTE_METADATA_DIR = 'note-metadata' +export const NOTE_METADATA_SUFFIX = '.metadata.json' export const TEMPLATES_DIR = '.zennotes/templates' export const RESERVED_ROOT_NAMES = new Set([ @@ -141,6 +147,14 @@ export function isMarkdownPath(p: string): boolean { return p.toLowerCase().endsWith('.md') } +/** Desktop's refusal (note-sidecars.ts `leftoverCommentsMessage`), word for + * word, when a rename or move lands on a name an earlier note left comments + * under: every writer refuses the same way and names the file to move aside. */ +export function leftoverCommentsMessage(notePath: string): string { + const sidecar = noteCommentsSidecarPath(INTERNAL_VAULT_DIR, notePath) + return `Comments from an earlier note named “${stemName(notePath)}” are still in ${sidecar}. Move or delete that file to use this name.` +} + // --------------------------------------------------------------------------- // Folder mapping — mirrors desktop folderForRelativePath, remap-aware since // 2.20 (vault.json `systemFolderPaths`): only the RESOLVED name of each diff --git a/src/bridge/vault-fs.ts b/src/bridge/vault-fs.ts index 05456a0..8806506 100644 --- a/src/bridge/vault-fs.ts +++ b/src/bridge/vault-fs.ts @@ -64,8 +64,11 @@ import { isExcalidrawPath, isMarkdownPath, joinPath, + leftoverCommentsMessage, NOTE_COMMENTS_DIR, NOTE_COMMENTS_SUFFIX, + NOTE_METADATA_DIR, + NOTE_METADATA_SUFFIX, RESERVED_ROOT_NAMES, resolveSafeRel, sanitizeNoteTitle, @@ -688,20 +691,37 @@ export class MobileVault { } } + /** + * Move a note with its comments and its creation date, as desktop's + * note-sidecars.ts `relocateNote` does; a sidecar the note lacks is not + * moved. A creation date already at a destination with no note beside it + * belongs to nobody (its note left outside ZenNotes) and is discarded rather + * than inherited (ZenNotes#839). Leftover comments there are another note's + * discussion: taking it over and deleting it are both wrong, so the move is + * refused. + */ private async relocateNote(oldPath: string, newPath: string): Promise { + const comments = this.commentsPathFor(newPath) + const metadata = this.metadataPathFor(newPath) + if (await this.fs.statVerified(newPath) === null) { + if (await this.fs.statVerified(comments) !== null) throw new Error(leftoverCommentsMessage(newPath)) + if (await this.fs.statVerified(metadata) === 'file') await this.fs.deleteFile(metadata) + } await relocateVaultEntries(this.relocationIO(), [ { from: oldPath, to: newPath, required: true }, - { from: this.commentsPathFor(oldPath), to: this.commentsPathFor(newPath) } + { from: this.commentsPathFor(oldPath), to: comments }, + { from: this.metadataPathFor(oldPath), to: metadata } ]) this.invalidateMeta(oldPath) } - /** Detach both trees before cleanup so a failed move can restore the original. */ - private async detachContent(path: string, comments: string, required = true): Promise { + /** Detach the content and both sidecar trees before cleanup so a failed move can restore the original. */ + private async detachContent(path: string, comments: string, metadata: string, required = true): Promise { const temporary = `${INTERNAL_VAULT_DIR}/delete-${uuid()}` const moves: VaultRelocation[] = [ { from: path, to: `${temporary}/content`, required }, - { from: comments, to: `${temporary}/comments` } + { from: comments, to: `${temporary}/comments` }, + { from: metadata, to: `${temporary}/metadata` } ] await relocateVaultEntries(this.relocationIO(), moves) try { @@ -811,7 +831,12 @@ export class MobileVault { async emptyTrash(): Promise { const trashDir = await this.folderRootRel('trash') - await this.detachContent(trashDir, `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${trashDir}`, false) + await this.detachContent( + trashDir, + `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${trashDir}`, + `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${trashDir}`, + false + ) for (const key of [...this.metaCache.keys()]) { if (key.startsWith(`${trashDir}/`)) this.invalidateMeta(key) } @@ -821,7 +846,7 @@ export class MobileVault { async deleteNote(relPath: string): Promise { const rel = resolveSafeRel(relPath) const folder = (await this.folderOf(rel)) ?? 'trash' - await this.detachContent(rel, this.commentsPathFor(rel)) + await this.detachContent(rel, this.commentsPathFor(rel), this.metadataPathFor(rel)) this.invalidateMeta(rel) emitVaultChange({ kind: 'unlink', path: rel, folder, scope: 'content' }) } @@ -912,10 +937,15 @@ export class MobileVault { const settingsPath = `${INTERNAL_VAULT_DIR}/vault.json` const hadSettings = await this.fs.statVerified(settingsPath) !== null const originalSettings = hadSettings ? await this.fs.readText(settingsPath) : null + // A creation-date tree already at the destination refuses the rename, as + // desktop's relocateFolderTrees does: only a single note's stale date is + // discarded (relocateNote). await relocateVaultEntries(this.relocationIO(), [ { from: oldRel, to: newRel, required: true }, { from: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${oldRel}`, - to: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${newRel}` } + to: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${newRel}` }, + { from: `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${oldRel}`, + to: `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${newRel}` } ], async () => { try { await this.setVaultSettings({ @@ -947,7 +977,11 @@ export class MobileVault { const clean = subpath.replace(/^\/+|\/+$/g, '') if (!clean) return const rel = resolveSafeRel(joinPath(topRel, clean)) - await this.detachContent(rel, `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${rel}`) + await this.detachContent( + rel, + `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${rel}`, + `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${rel}` + ) for (const key of [...this.metaCache.keys()]) { if (key.startsWith(`${rel}/`)) this.invalidateMeta(key) } @@ -981,6 +1015,12 @@ export class MobileVault { return `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${rel}${NOTE_COMMENTS_SUFFIX}` } + /** `.zennotes/note-metadata/.metadata.json`, the creation date desktop + * keeps for the note (see NOTE_METADATA_DIR). */ + private metadataPathFor(rel: string): string { + return `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${rel}${NOTE_METADATA_SUFFIX}` + } + async readNoteComments(relPath: string): Promise { const rel = resolveSafeRel(relPath) const raw = await this.fs.readTextOrNull(this.commentsPathFor(rel)) diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index 1ce1b2f..0ce80c3 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -27,13 +27,8 @@ import { usePins, toggleNotePin, toggleFolderPin } from './pins' import { archiveNote, openNoteMenu, trashNote } from './note-actions' import { refreshVault } from './refresh' import { SwipeRow } from './SwipeRow' +import { drawerFileRows, fileExtensionLabel, type DrawerFileRow } from './browse-files' import { getStoragePref, icloudStatus } from '../bridge/icloud' -import { answerExternalVaultReplace, getExternalVaultRef } from '../bridge/folder-picker' -import { - FOLDER_VAULT_HINT, - folderVaultReplaceNotice, - type FolderVaultReplaceNotice -} from '../bridge/folder-vault-replace' import { ICLOUD_VAULT_ROOT_PREFIX, VAULT_ROOT_PREFIX, @@ -44,6 +39,7 @@ import { deleteVault, moveVault, forgetExternalVault, + currentExternalVaultRoot, type MobileVaultEntry } from '../bridge/mobile-bridge' import { sanitizeNoteTitle } from '../bridge/vault-core' @@ -98,7 +94,9 @@ const D = { chevDown: 'M6 9l6 6 6-6', more: 'M6 12h.01M12 12h.01M18 12h.01', pencil: 'M17 3a2.85 2.85 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z', - pin: 'M12 17v5M9 3h6l-1 7 3 2v3H7v-3l3-2-1-7z' + pin: 'M12 17v5M9 3h6l-1 7 3 2v3H7v-3l3-2-1-7z', + // The editor toolbar's "Attach file" glyph. + file: 'M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l8.57-8.57A4 4 0 0118 8.84l-8.59 8.57a2 2 0 01-2.83-2.83l8.49-8.48' } /** WKWebView leaves the soft keyboard up when a focused input unmounts @@ -148,14 +146,6 @@ function NewVaultSheet({ const [cloudOk, setCloudOk] = useState(null) const [busy, setBusy] = useState<'create' | 'pick' | null>(null) const [error, setError] = useState('') - // Set while the sheet asks before a pick replaces the Files folder vault. - // The form stays mounted under the question, hidden, so the name and the - // location survive a Cancel, and the autofocused name field cannot come - // back and raise the keyboard over the Files picker. - const [replacing, setReplacing] = useState<{ - notice: FolderVaultReplaceNotice - bookmark: string - } | null>(null) useEffect(() => { let alive = true @@ -201,70 +191,35 @@ function NewVaultSheet({ } // Escape hatch to the real file manager: the native Files picker (any - // provider — iCloud Drive folders, On My iPhone, Working Copy, …). The + // provider: iCloud Drive folders, On My iPhone, Working Copy, …). The // picked/created folder itself becomes the vault, so the name field does - // not apply; a cancelled picker returns to this sheet. `answered` carries - // the yes this sheet already got, so the bridge does not ask again. - const pickFolder = (answered: string | null): void => { - setReplacing(null) + // not apply; a cancelled picker returns to this sheet. The chosen folder + // joins the Files folders already listed. Whether the pick switched vaults + // is read off the vault's identity, not its shown root: two folders from + // different providers can both show as "Files › Notes". + const chooseFolder = (): void => { + if (busy) return setBusy('pick') setError('') dismissKeyboard() - const before = getShellSnapshot().vault?.root ?? null - const release = answered === null ? null : answerExternalVaultReplace(answered) + const before = activeVaultStateKey() pickLocalVault() .then(() => { - const after = getShellSnapshot().vault?.root ?? null - if (after !== before) onDone(true) + if (activeVaultStateKey() !== before) onDone(true) else setBusy(null) }) .catch((err) => { setError(String((err as Error)?.message ?? err)) setBusy(null) }) - .finally(() => release?.()) - } - - // A pick replaces the Files folder vault that is already set (one - // bookmark), so the sheet asks first. Asking here, before the store starts - // a vault change, is what lets Cancel leave everything as it was. - const chooseFolder = (): void => { - if (busy) return - setError('') - dismissKeyboard() - const current = getExternalVaultRef() - const notice = folderVaultReplaceNotice(current) - if (current && notice) setReplacing({ notice, bookmark: current.bookmark }) - else pickFolder(null) } return ( <>
-
-
{replacing?.notice.title ?? 'New Vault'}
- {replacing && ( -
-

{replacing.notice.body}

-
- - -
-
- )} -