From 3d90d34fb061e6ee32fa93a15522c7d9075a3af2 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 25 Sep 2026 08:17:35 -0500 Subject: [PATCH 1/2] feat(mobile): adopt core 2.56.1, a vault's own name on the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared core moves from 2.55.0 to 2.56.1 (`core-2.56.1-core.hc872c08b6872aec4`, built from ZenNotes/zennotes `f8b24c09` with a clean tree: the desktop 2.56.0 release plus the one core fix the phones needed). What the phone gets from 2.56.0: a vault can go by a name of its own while its folder keeps its name (ZenNotes/zennotes#692; Rename Vault… in the command palette, reached from the ••• sheet's All commands…, or the Vault name field under Settings, Vault), a Markdown table converts into a database linked from the note (#832; Convert Table to Database… in the palette with the cursor in a table), Settings search reaches every setting and knows where new tasks go, a task added or moved from the calendar panel joins the note's Tasks section (#851), a task's code block stays under its text in Preview (#849), undo after the open note changed on disk (a sync, for one) starts a clean history instead of saving a mixed text (#852), and the in-app Help reads as one page. The template shortcuts (#847) are keyboard-only and do nothing here. What the shell had to do for #692. The core takes a vault's folder name from its root, and this shell's root is a label ("On My iPhone › ZenNotes › docs"), so without help the first settings save renamed the vault to the whole label; 2.56.1 lets a host name the folder itself, and `currentVaultInfo` now hands over `folderName`. The core only re-derives the name after a settings save, so `describeCurrentVault` resolves the display name from vault.json whenever a bridge method returns the open vault (desktop does the same in main). The Vaults sheet lists folders, and rename and delete act on the folder, so it now marks the open vault by the folder name the bridge holds (`currentVaultFolderName`) rather than by the shown name, which the display name had made a different string: before this, a renamed vault lost its check mark and its Delete and Rename guards. Each row shows the name the vault goes by (one vault.json read per local or iCloud folder; an evicted iCloud file keeps the folder name rather than waiting on a download), and the manage view names the folder beside a display name that differs. A test pins that a name written by the desktop survives a settings save from this device. Verified on the iPhone 17 Pro Max simulator with a throwaway XCUITest: a cleared name shows the folder, two settings saves (a favorite on and off) leave it, Rename Vault… through the palette shows the new name marked current with "(folder My Vault) … currently open" and Delete disabled, and clearing the name goes back to the folder. Adopted with `npm run core:adopt -- core-2.56.1-core.hc872c08b6872aec4 --source f8b24c09af1828b5242d572c7636df83236e2586`: every archive's SHA-256 and SHA-512 checked against its provenance, all three recording that source commit; the boundary check passed without an override. --- README.md | 4 +- package-lock.json | 30 +++---- package.json | 6 +- src/bridge/mobile-bridge.ts | 84 +++++++++++++++--- src/bridge/native-fs.ts | 35 ++++++++ src/bridge/vault-lifecycle.test.ts | 17 ++++ src/ui-mobile/MobileDrawer.tsx | 21 +++-- vendor/zennotes/manifest.json | 32 +++---- ...app-core-2.55.0-core.h0743bd3af2a4204a.tgz | Bin 3194506 -> 0 bytes ...app-core-2.56.1-core.hc872c08b6872aec4.tgz | Bin 0 -> 3211841 bytes ...ct-2.55.0-boundaries.hd2f617179eb16ef2.tgz | Bin 37516 -> 0 bytes ...ct-2.56.1-boundaries.h3434125ac49b85c7.tgz | Bin 0 -> 38317 bytes ...in-2.55.0-boundaries.hd2f617179eb16ef2.tgz | Bin 261501 -> 0 bytes ...in-2.56.1-boundaries.h3434125ac49b85c7.tgz | Bin 0 -> 264560 bytes 14 files changed, 174 insertions(+), 55 deletions(-) delete mode 100644 vendor/zennotes/zennotes-app-core-2.55.0-core.h0743bd3af2a4204a.tgz create mode 100644 vendor/zennotes/zennotes-app-core-2.56.1-core.hc872c08b6872aec4.tgz delete mode 100644 vendor/zennotes/zennotes-bridge-contract-2.55.0-boundaries.hd2f617179eb16ef2.tgz create mode 100644 vendor/zennotes/zennotes-bridge-contract-2.56.1-boundaries.h3434125ac49b85c7.tgz delete mode 100644 vendor/zennotes/zennotes-shared-domain-2.55.0-boundaries.hd2f617179eb16ef2.tgz create mode 100644 vendor/zennotes/zennotes-shared-domain-2.56.1-boundaries.h3434125ac49b85c7.tgz diff --git a/README.md b/README.md index cac5ed1..81d4b62 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.55.0-core.h0743bd3af2a4204a](https://github.com/ZenNotes/zennotes/releases/tag/core-2.55.0-core.h0743bd3af2a4204a) -(desktop commit `00db7036`, clean tree). +[core-2.56.1-core.hc872c08b6872aec4](https://github.com/ZenNotes/zennotes/releases/tag/core-2.56.1-core.hc872c08b6872aec4) +(desktop commit `f8b24c09`, 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/package-lock.json b/package-lock.json index 5a85ff7..58e33cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.4.0", "@xyflow/react": "^12.11.6", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.55.0-core.h0743bd3af2a4204a.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.55.0-boundaries.hd2f617179eb16ef2.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.55.0-boundaries.hd2f617179eb16ef2.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.56.1-core.hc872c08b6872aec4.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.56.1-boundaries.h3434125ac49b85c7.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.56.1-boundaries.h3434125ac49b85c7.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.15", "function-plot": "^1.25.3", @@ -3351,9 +3351,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.55.0-core.h0743bd3af2a4204a", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.55.0-core.h0743bd3af2a4204a.tgz", - "integrity": "sha512-FuPj4V2Am8y9FctFhA4bLyH0OoiDBMApx4YoFx5j3e3yVNaU5oniCQJWzjBFqMQW7H2Nsf8nd7nbbm0iF4t4OA==", + "version": "2.56.1-core.hc872c08b6872aec4", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.56.1-core.hc872c08b6872aec4.tgz", + "integrity": "sha512-kiNliezNdk0EOT0Ic2DnQTBoprlqXkMPijSVqku1fc/z1RqyS6m9Xff90TH8RP6kpbKe6fYojK8qGdgnTUTmYw==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3381,8 +3381,8 @@ "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.55.0-boundaries.hd2f617179eb16ef2", - "@zennotes/shared-domain": "2.55.0-boundaries.hd2f617179eb16ef2", + "@zennotes/bridge-contract": "2.56.1-boundaries.h3434125ac49b85c7", + "@zennotes/shared-domain": "2.56.1-boundaries.h3434125ac49b85c7", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3426,18 +3426,18 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.55.0-boundaries.hd2f617179eb16ef2", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.55.0-boundaries.hd2f617179eb16ef2.tgz", - "integrity": "sha512-DrLL7auzePYE/JzBTDX+S/LMbwWwd2chaYXiqunC2FL567bTsvEqdhAP9koKEjmxTBuoCxEFhu5+k3jhC2Jtcw==", + "version": "2.56.1-boundaries.h3434125ac49b85c7", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.56.1-boundaries.h3434125ac49b85c7.tgz", + "integrity": "sha512-W8XNcjixpGxdNHPs03xiyuA1Q/4ZnLcdX2O/ifEWLpZlqmwM9btdIochzsJlqU5eUZvlUFa7zibAlGFr2FRKzQ==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.55.0-boundaries.hd2f617179eb16ef2", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.55.0-boundaries.hd2f617179eb16ef2.tgz", - "integrity": "sha512-CPD0OdmOufvanqcNV8ktOJraIVqQJ0tMpMZe7xYPxxssMupSwhqMx0NMve8/q+mYeln5LzQZ4Pmi33YUbdFfDA==", + "version": "2.56.1-boundaries.h3434125ac49b85c7", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.56.1-boundaries.h3434125ac49b85c7.tgz", + "integrity": "sha512-7/Qau3EgKTOIdx9BaOMWffvcISvP/cILDHd4y8eEqH3IkjUarWj7sVY1e9riuVODcGLCGo/6a8O1IUavCaF1ng==", "license": "MIT", "dependencies": { - "@zennotes/bridge-contract": "2.55.0-boundaries.hd2f617179eb16ef2", + "@zennotes/bridge-contract": "2.56.1-boundaries.h3434125ac49b85c7", "lz-string": "^1.5.0" } }, diff --git a/package.json b/package.json index 9493d94..0a657f9 100644 --- a/package.json +++ b/package.json @@ -67,9 +67,9 @@ "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.3.2", "zustand": "^5.0.15", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.55.0-core.h0743bd3af2a4204a.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.55.0-boundaries.hd2f617179eb16ef2.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.55.0-boundaries.hd2f617179eb16ef2.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.56.1-core.hc872c08b6872aec4.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.56.1-boundaries.h3434125ac49b85c7.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.56.1-boundaries.h3434125ac49b85c7.tgz", "@lezer/common": "^1.5.2" }, "devDependencies": { diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 5217979..f1cbd86 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -39,6 +39,7 @@ import type { VaultTextSearchMatch } from '@zennotes/shared-domain/ipc' import { createDatabaseOps } from '@zennotes/shared-domain/database-ops' +import { normalizeVaultDisplayName, resolveVaultName } from '@zennotes/shared-domain/vault-display-name' import type { CustomCodeLanguage, CustomCodeLanguageInstallInput, @@ -58,7 +59,7 @@ import type { McpServerRuntime } from '@zennotes/shared-domain/mcp-clients' import { MobileVault } from './vault-fs' -import { listVaultDirs, VAULTS_DIR } from './native-fs' +import { listVaultDirs, readVaultDisplayName, readVaultDisplayNameAtUrl, VAULTS_DIR } from './native-fs' import { randomUUID } from './uuid' import { Directory, Filesystem } from '@capacitor/filesystem' import { @@ -166,7 +167,12 @@ export const EXTERNAL_VAULT_ROOT = 'zn://external-vault' export interface MobileVaultEntry { root: string + /** The folder's name on disk: what rename and delete act on, and what the + * open vault's `folderName` is compared with to mark it current. */ name: string + /** The name the vault goes by when its vault.json carries one (ZenNotes + * #692), normalized; the sheet shows it in place of the folder name. */ + displayName?: string tier: 'local' | 'icloud' | 'external' } @@ -210,17 +216,25 @@ async function looksLikeVaultDir(url: string): Promise { export async function listSwitchableVaults(): Promise { const out: MobileVaultEntry[] = [] for (const d of await listVaultDirs()) { - out.push({ root: `${VAULT_ROOT_PREFIX}${d.name}`, name: d.name, tier: 'local' }) + const displayName = normalizeVaultDisplayName(await readVaultDisplayName(d.name)) + out.push({ + root: `${VAULT_ROOT_PREFIX}${d.name}`, + name: d.name, + tier: 'local', + ...(displayName ? { displayName } : {}) + }) } const status = await icloudStatus().catch(() => null) if (status?.available && status.rootUrl) { for (const name of filterCloudVaultNames(status.vaults ?? [])) { const url = `${status.rootUrl}/${encodeURIComponent(name)}` if (!(await looksLikeVaultDir(url))) continue + const displayName = normalizeVaultDisplayName(await readVaultDisplayNameAtUrl(url)) out.push({ root: `${ICLOUD_VAULT_ROOT_PREFIX}${encodeURIComponent(name)}`, name, - tier: 'icloud' + tier: 'icloud', + ...(displayName ? { displayName } : {}) }) } } @@ -417,7 +431,45 @@ function currentVaultInfo(): VaultInfo | null { const remote = remoteVaultInfo() if (remote) return remote if (!vault) return null - return { root: friendlyVaultRoot(vault), name: vault.name } + // The root is a label, not a path, so the core cannot read the folder's + // name off it; folderName is what the display name falls back to and what + // the Vault name field shows as its placeholder (ZenNotes #692). + return { root: friendlyVaultRoot(vault), name: vault.name, folderName: vault.name } +} + +/** + * The open vault as the app names it: the display name from its vault.json + * when it has one (ZenNotes #692), else the folder name. Desktop resolves + * this in main (describeVault) before the renderer ever sees a VaultInfo, + * and the renderer only re-derives the name after a settings save or an + * external vault.json change, so a shell that handed over the folder name + * would show it until the first save. Every bridge method that returns the + * opened vault goes through here; a remote workspace's name comes from its + * server and passes untouched. The settings read is cached after the first + * open, and a vault whose vault.json cannot be read keeps its folder name, + * the way it always did. + */ +/** + * The open local vault's folder name, null in a remote workspace or before + * boot. The Vaults sheet lists folders and must mark the one that is open + * whatever the vault is called (rename and delete act on the folder), and + * the core's shell snapshot carries the vault's shown name, not its folder, + * so the sheet asks the bridge, which holds the MobileVault by folder name. + */ +export function currentVaultFolderName(): string | null { + if (remoteVaultInfo()) return null + return vault?.name ?? null +} + +async function describeCurrentVault(): Promise { + const info = currentVaultInfo() + if (!info || remoteVaultInfo() || !vault) return info + try { + const settings = await vault.getVaultSettings() + return { ...info, name: resolveVaultName(settings.displayName, info.name) } + } catch { + return info + } } /** @@ -463,7 +515,7 @@ async function openVaultByName(name: string, cloudRootUri: string | null = null) await next.open() vault = next localStorage.setItem(CURRENT_VAULT_KEY, name) - return currentVaultInfo() as VaultInfo + return (await describeCurrentVault()) as VaultInfo } /** @@ -913,25 +965,29 @@ export const mobileBridge: ZenBridge = { await disconnectRemote() // Reopen the remembered local tier so the app lands somewhere real. await openLocalVaultTier() - return currentVaultInfo() + return describeCurrentVault() }, // Mobile never boots into a broken workspace (an unreachable remote falls // back to the local vault in bootVault), so "retry" simply reports where // the app already landed. - retryWorkspaceBoot: async () => currentVaultInfo(), + retryWorkspaceBoot: () => describeCurrentVault(), listRemoteWorkspaceProfiles: () => listProfiles(), saveRemoteWorkspaceProfile: (input) => saveProfile(input), deleteRemoteWorkspaceProfile: (id) => deleteProfile(id), connectRemoteWorkspaceProfile: (id) => connectRemoteProfile(id), - getCurrentVault: async () => currentVaultInfo(), + getCurrentVault: () => describeCurrentVault(), listLocalVaults: async (): Promise => { const dirs = await listVaultDirs() - return dirs.map((d) => ({ - root: `${VAULT_ROOT_PREFIX}${d.name}`, - name: d.name, - lastOpenedAt: d.mtime - })) + // The switcher shows each vault by the name it goes by (#692): one small + // vault.json read per folder, the folder name when there is none. + return Promise.all( + dirs.map(async (d) => ({ + root: `${VAULT_ROOT_PREFIX}${d.name}`, + name: resolveVaultName(await readVaultDisplayName(d.name), d.name), + lastOpenedAt: d.mtime + })) + ) }, openLocalVault: async (root: string) => { // One entry point for switching to any device-reachable vault: local @@ -959,7 +1015,7 @@ export const mobileBridge: ZenBridge = { setStoragePref('local') return await openVaultByName(vaultNameFromRoot(root)) }, - closeVault: async () => currentVaultInfo(), + closeVault: () => describeCurrentVault(), pickVault: async () => { const picked = await pickExternalVault() if (!picked) return null diff --git a/src/bridge/native-fs.ts b/src/bridge/native-fs.ts index 199182f..6fa73fe 100644 --- a/src/bridge/native-fs.ts +++ b/src/bridge/native-fs.ts @@ -305,3 +305,38 @@ export async function listVaultDirs(): Promise<{ name: string; mtime: number }[] return [] } } + +/** + * The raw `displayName` a local vault carries in its own vault.json + * (ZenNotes #692), or null when the vault has none or the file cannot be + * read. For the vault switcher, which lists vaults that are not open and so + * have no MobileVault to ask; the caller normalizes and falls back to the + * folder name, the way desktop's describeVault does. + */ +export async function readVaultDisplayName(vaultName: string): Promise { + return readDisplayNameFrom({ + path: `${VAULTS_DIR}/${vaultName}/.zennotes/vault.json`, + directory: Directory.Documents + }) +} + +/** + * The same for a vault addressed by a file URL, which is how the iCloud tier + * lists its vaults (see looksLikeVaultDir). One read attempt, no download + * wait: an evicted vault.json answers null and the vault keeps its folder + * name in the switcher, rather than every listing waiting on iCloud. + */ +export async function readVaultDisplayNameAtUrl(url: string): Promise { + return readDisplayNameFrom({ path: `${url}/.zennotes/vault.json` }) +} + +async function readDisplayNameFrom(loc: { path: string; directory?: Directory }): Promise { + try { + const res = await Filesystem.readFile({ ...loc, encoding: Encoding.UTF8 }) + const parsed: unknown = JSON.parse(typeof res.data === 'string' ? res.data : '') + const name = (parsed as { displayName?: unknown } | null)?.displayName + return typeof name === 'string' ? name : null + } catch { + return null + } +} diff --git a/src/bridge/vault-lifecycle.test.ts b/src/bridge/vault-lifecycle.test.ts index 6f58675..45a8961 100644 --- a/src/bridge/vault-lifecycle.test.ts +++ b/src/bridge/vault-lifecycle.test.ts @@ -108,3 +108,20 @@ test('native absent-file reads propagate provider failures instead of allowing s const permission = new Error('Permission denied') await assert.rejects(NativeFs.prototype.readTextOrNull.call({ readText: async () => { throw permission } }, 'schema.json'), permission) }) +// ZenNotes #692: the desktop writes the vault's display name into vault.json. +// The phone's settings pass every key through, so a save from this device +// (toggling a favorite is enough) must keep the name, and a read must hand +// it to the core, which names the vault by it. +test('a vault display name written by desktop survives a phone settings save (ZenNotes #692)', async () => { + const { fs, vault } = fixture() + fs.files.set('.zennotes/vault.json', JSON.stringify({ + ...structuredClone(DEFAULT_VAULT_SETTINGS), displayName: 'Acme API docs' + })) + Object.assign(vault, { settingsCache: null }) + const settings = await vault.getVaultSettings() + assert.equal(settings.displayName, 'Acme API docs') + await vault.setVaultSettings({ ...settings, favorites: ['Notes/Work/One.md'] }) + const written = JSON.parse(fs.files.get('.zennotes/vault.json')!) + assert.equal(written.displayName, 'Acme API docs') + assert.deepEqual(written.favorites, ['Notes/Work/One.md']) +}) diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index 9f25d00..ec5ee54 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -32,6 +32,7 @@ import { ICLOUD_VAULT_ROOT_PREFIX, VAULT_ROOT_PREFIX, activeVaultStateKey, + currentVaultFolderName, listSwitchableVaults, renameVault, deleteVault, @@ -330,7 +331,14 @@ const TIER_SECTIONS = [ ] as const export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Element { - const currentName = useShellSnapshot().vault?.name ?? null + // The open vault is matched by its folder name, which is what an entry's + // `name` is: rename and delete act on the folder, so the guards below must + // hold for the folder that is open whatever the vault is called. The + // bridge names the folder; the snapshot's vault name is the display name + // (ZenNotes #692), which is what the rows show: the snapshot's for the + // open vault (the shell resolves it) and each entry's for the others. + const currentDisplayName = useShellSnapshot().vault?.name ?? null + const currentFolder = currentVaultFolderName() const { mode: workspaceMode, remoteProfileId, remoteProfiles } = useWorkspaceSnapshot() const [entries, setEntries] = useState(null) const [view, setView] = useState({ kind: 'list' }) @@ -358,7 +366,9 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele // whose friendly root string varies by provider. const currentTier = workspaceMode === 'remote' ? 'remote' : getStoragePref() const isCurrent = (e: MobileVaultEntry): boolean => - currentTier === e.tier && e.name === currentName + currentTier === e.tier && e.name === currentFolder + const shownName = (e: MobileVaultEntry): string => + (isCurrent(e) ? currentDisplayName : null) ?? e.displayName ?? e.name /** Switch flows close the sheet and drawer on success. */ const act = (key: string, fn: () => Promise): void => { @@ -490,13 +500,13 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele ) } > - {entry.name} + {shownName(entry)} {current && ✓}