diff --git a/README.md b/README.md index 38585e3..5449fca 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. A clean checkout installs them with `npm ci`, without a source clone or sibling repository. The vendored set is the core release -[core-2.80.0-core.hc5a4589d040cd0da](https://github.com/ZenNotes/zennotes/releases/tag/core-2.80.0-core.hc5a4589d040cd0da) -(desktop commit `31520814`, clean tree). +[core-2.81.0-core.h4e6a38a3361b4748](https://github.com/ZenNotes/zennotes/releases/tag/core-2.81.0-core.h4e6a38a3361b4748) +(desktop commit `bfab9fdc`, clean tree). `npm run boundaries:check` verifies the pins, installed versions, singleton React/CodeMirror peers, and public export usage. `npm run core:adopt -- ` diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 5478548..5cf9855 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -624,12 +624,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -753,12 +753,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -775,12 +775,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -795,12 +795,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -817,12 +817,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -855,12 +855,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 54; + CURRENT_PROJECT_VERSION = 55; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.35.0; + MARKETING_VERSION = 1.36.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/package-lock.json b/package-lock.json index 38355ed..f67f454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.4.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.80.0-core.hc5a4589d040cd0da.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.81.0-core.h4e6a38a3361b4748.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.81.0-boundaries.h697bb3192aa5970b.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.81.0-boundaries.h697bb3192aa5970b.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", @@ -3666,9 +3666,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.80.0-core.hc5a4589d040cd0da", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.80.0-core.hc5a4589d040cd0da.tgz", - "integrity": "sha512-pfj1PIqD3RaQOLEvtAP6DagOOqZdnWiBxSH+fckx+STFMTS859gQaTlf2arGaCaRypy0bWMLm+H3LYpGmEDUEw==", + "version": "2.81.0-core.h4e6a38a3361b4748", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.81.0-core.h4e6a38a3361b4748.tgz", + "integrity": "sha512-0cEPF3+zsswihP+JCcxqfij+p0ssYxPHpt58EaGDgEx8LiYFdnTP9dnu67WliUNprIdHTKAdkrwmZ6Kc7hJFCQ==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3698,8 +3698,8 @@ "@replit/codemirror-vim": "^6.3.0", "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.80.0-boundaries.hc07ef9e2bef31a64", - "@zennotes/shared-domain": "2.80.0-boundaries.hc07ef9e2bef31a64", + "@zennotes/bridge-contract": "2.81.0-boundaries.h697bb3192aa5970b", + "@zennotes/shared-domain": "2.81.0-boundaries.h697bb3192aa5970b", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3743,19 +3743,19 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.80.0-boundaries.hc07ef9e2bef31a64", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", - "integrity": "sha512-Ia+UG7f1rU0ar6+lVdrNTrWpM03udlG6ehai9EN8R5yM3U05HZkFSaAA9shxMg9TrDvt9103FAep/U5XH6/mAg==", + "version": "2.81.0-boundaries.h697bb3192aa5970b", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.81.0-boundaries.h697bb3192aa5970b.tgz", + "integrity": "sha512-Aukkw1ZsDrTaSZ3L1fUjulNJHFpN8G/uFSeNFYpXxx5NziyL1ZYKlcO+6Mcc47qLKvHUryMhehqILZFJsXcnwQ==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.80.0-boundaries.hc07ef9e2bef31a64", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", - "integrity": "sha512-8qj/op680ka4qF8sIqGhZZfTd+ADjhneivst+T7dmS3HOjZaHQQskzjLRt1zSNngSXWqz9mxG03UCItYfDL2gA==", + "version": "2.81.0-boundaries.h697bb3192aa5970b", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.81.0-boundaries.h697bb3192aa5970b.tgz", + "integrity": "sha512-sS4/C0A/SSC8GN7cqm8wLUcBWCqenJM/BhfPFmHCKyle24EMJFbs3KPhy/4auLOErXi7Qf+wC0r+kVZmbLaciQ==", "license": "MIT", "dependencies": { "@scure/bip39": "2.4.0", - "@zennotes/bridge-contract": "2.80.0-boundaries.hc07ef9e2bef31a64", + "@zennotes/bridge-contract": "2.81.0-boundaries.h697bb3192aa5970b", "hash-wasm": "4.12.0", "lz-string": "^1.5.0" } diff --git a/package.json b/package.json index 55bc3b1..ec8d5f9 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.4.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.80.0-core.hc5a4589d040cd0da.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.80.0-boundaries.hc07ef9e2bef31a64.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.81.0-core.h4e6a38a3361b4748.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.81.0-boundaries.h697bb3192aa5970b.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.81.0-boundaries.h697bb3192aa5970b.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", diff --git a/src/bridge/database-open.test.ts b/src/bridge/database-open.test.ts new file mode 100644 index 0000000..0d35c17 --- /dev/null +++ b/src/bridge/database-open.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' +import { missingDatabaseAsNull } from './database-open.ts' + +const doc = { relPath: 'inbox/Board.base/data.csv' } as never + +describe('Opening a database whose folder is gone', () => { + it('answers null for a missing database, so core forgets it and closes its tab', async () => { + const open = missingDatabaseAsNull(async (rel) => { + throw new Error(`Database not found: ${rel}`) + }) + assert.equal(await open('inbox/Projects/Untitled Kanban.base/data.csv'), null) + }) + + it('passes a database through, and every other failure still throws', async () => { + assert.equal(await missingDatabaseAsNull(async () => doc)('x'), doc) + const unreadable = missingDatabaseAsNull(async () => { + throw new Error('EACCES: permission denied') + }) + await assert.rejects(unreadable('x'), /permission denied/) + }) + + it('is what the bridge hands core', () => { + const bridge = readFileSync(new URL('./mobile-bridge.ts', import.meta.url), 'utf8') + assert.match(bridge, /openDatabase: openDatabaseOrThrow,/) + assert.match(bridge, /const openDatabase = missingDatabaseAsNull\(openDatabaseOrThrow\)/) + }) + + it('reads the contract core and the shared ops keep', () => { + const pkg = (path: string): string => + readFileSync(new URL(`../../node_modules/@zennotes/${path}`, import.meta.url), 'utf8') + // The shared ops throw for an absent data.csv ... + assert.match(pkg('shared-domain/dist/database-ops.js'), /if \(csvText === null\)\n\s+throw new Error\(`Database not found: \$\{rel\}`\);/) + // ... core forgets a database the bridge answers null for, and toasts a + // throw once, recording it so the grid does not ask again (core 2.81.0). + const store = pkg('app-core/dist/store.js') + assert.match(store, /if \(!doc\) \{[\s\S]{0,240}await get\(\)\.forgetDatabase\(csvPath\);/) + assert.match(store, /const message = humanIpcError\(err, 'Could not open database'\);[\s\S]{0,240}databasesLoadError[\s\S]{0,240}\.addToast\(message, 'error'\);/) + assert.match(pkg('bridge-contract/dist/bridge.d.ts'), /openDatabase\(relPath: string\): Promise;/) + }) +}) diff --git a/src/bridge/database-open.ts b/src/bridge/database-open.ts new file mode 100644 index 0000000..fcc99b8 --- /dev/null +++ b/src/bridge/database-open.ts @@ -0,0 +1,26 @@ +import type { DatabaseDoc } from '@zennotes/bridge-contract/databases' + +/** + * A database's tab can outlive its folder: deleted in a file manager, by sync + * from another device, or by another app sharing the vault. The bridge + * contract answers null for a missing database, as desktop's main process + * does, and core then forgets the database and closes its tab. + * + * The shared database ops throw "Database not found" for it instead, and + * core's grid toasted that error and asked again on its next render: one + * relaunch with such a tab open stacked 780 "Database not found" toasts over + * the app. Only that error becomes null; anything else (a file that cannot + * be read, a schema that cannot be parsed) still throws. + */ +export function missingDatabaseAsNull( + open: (relPath: string) => Promise +): (relPath: string) => Promise { + return async (relPath) => { + try { + return await open(relPath) + } catch (err) { + if (err instanceof Error && err.message.startsWith('Database not found')) return null + throw err + } + } +} diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 112fd35..7da6365 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -40,6 +40,7 @@ import type { VaultTextSearchMatch } from '@zennotes/shared-domain/ipc' import { createDatabaseOps } from '@zennotes/shared-domain/database-ops' +import { missingDatabaseAsNull } from './database-open' import { normalizeVaultDisplayName, resolveVaultName } from '@zennotes/shared-domain/vault-display-name' import type { CustomCodeLanguage, @@ -725,7 +726,7 @@ const dbOps = createDatabaseOps({ }) const { - openDatabase, + openDatabase: openDatabaseOrThrow, writeDatabaseRows, writeDatabaseSchema, createDatabase, @@ -733,6 +734,8 @@ const { renameDatabase, listDatabases } = dbOps +// A tab whose database is gone answers null (database-open.ts). +const openDatabase = missingDatabaseAsNull(openDatabaseOrThrow) // -------------------------------------------------------------------- // Asset URL resolution (WebView-loadable file URLs) diff --git a/src/main.tsx b/src/main.tsx index 32020a1..b0c9812 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -29,6 +29,8 @@ import { installDeepLinks } from './ui-mobile/deep-links' import { mountMobileShell } from './ui-mobile/MobileShell' import { installHomeGuard } from './ui-mobile/nav' import { refreshVault, wireICloudLiveRefresh } from './ui-mobile/refresh' +import { installSoftReturn } from './ui-mobile/soft-return' +import { installFieldKeyboardReveal } from './ui-mobile/field-keyboard-reveal' import { isPhoneDevice, watchPhoneClass } from './viewport' import { waitAtBoot } from './boot-wait' import './ui-mobile/mobile.css' @@ -51,6 +53,12 @@ function wireKeyboard(): void { void Keyboard.setResizeMode({ mode: isPhoneDevice() ? KeyboardResize.Native : KeyboardResize.None }).catch(() => {}) + // Native resize hides the keyboard from CodeMirror's own check, so Return + // at a sentence start would reach the editor as Shift+Return. + if (isPhoneDevice()) installSoftReturn() + // It hides the keyboard from WebKit too, which then leaves a focused field + // outside the editor under it. + if (isPhoneDevice()) installFieldKeyboardReveal() // Publish the layout decision to CSS as .zn-phone and keep it in step // with Split View / Stage Manager resizes. watchPhoneClass() diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index 15d7d83..198a6ff 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -11,7 +11,7 @@ import ReactDOM from 'react-dom/client' import { getShellSnapshot, useShellSnapshot, setNoteSortOrder, type NoteSortOrder } from '@zennotes/app-core/shell' import { getBrowseSnapshot, useBrowseSnapshot, getBrowseDirectory, requestCreateBrowseFolder, requestCreateBrowseNote, requestRenameBrowseFolder, requestRenameBrowseDatabase, requestMoveBrowseDirectory, - requestDeleteBrowseDirectory, openBrowseFolderGallery } from '@zennotes/app-core/browse' + requestDeleteBrowseDirectory, openBrowseFolderGallery, createBrowseKanban } from '@zennotes/app-core/browse' import { useWorkspaceSnapshot, openLocalVault, pickLocalVault, refreshRemoteProfiles, connectRemoteWorkspace, connectRemoteProfile, changeRemoteVaultPath, deleteRemoteProfile } from '@zennotes/app-core/workspace' import { openNote, openAppPage } from '@zennotes/app-core/navigation' @@ -100,6 +100,8 @@ const D = { 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', gallery: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', + // The New sheet's New Kanban glyph (MobileShell ICONS.tabs). + kanban: 'M4 6h16M4 6v12h16V6M9 6v12', // 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' } @@ -1160,6 +1162,15 @@ function MobileDrawerBody(props: { setFolderMenu(null) go(() => openBrowseFolderGallery(host, subpath).catch(reportActionError)) } + // The desktop folder menu's New Kanban (core 2.81's createBrowseKanban): a + // board seeded with To do, Doing and Done lands in this folder and opens on + // its Board. The New sheet's row puts one where new databases go instead. + // Core refuses a folder inside a database, which the drawer never lists. + const newKanbanIn = (subpath: string): void => { + const host = folderMenu?.host ?? captureMobileWorkspace() + setFolderMenu(null) + go(() => createBrowseKanban(host, subpath).catch(reportActionError)) + } const newFolderHere = (): void => { void requestCreateBrowseFolder(captureMobileWorkspace(), path).catch(reportActionError) } @@ -1451,6 +1462,16 @@ function MobileDrawerBody(props: { Open as Gallery )} + {folderMenu.kind === 'folder' && ( + + )} +