From c9ecdeb0f7b656690f9bbaca2669aa4077f45989 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 18 Sep 2026 22:19:41 -0500 Subject: [PATCH 1/2] feat: adopt core 2.53.0, Favorites on the phone, gestures on every page, folder-vault moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core packages: the vendored @zennotes/app-core, bridge-contract and shared-domain archives move from 2.51.0 to the clean core-2.53.0-core.h598c8d004c9228a3 release (desktop commit 3a622639, tag v2.53.0, workingTreeDirty false). Each archive's SHA-256 and SHA-512 integrity matched its provenance file and the recorded source commit before vendoring; manifest.json is the app-core provenance file. tooling/check-core-boundary.mjs now refuses an archive whose manifest says workingTreeDirty or lacks a 40-hex source commit unless ZEN_ALLOW_DIRTY_CORE=1 is set for a local try-out. README names the new release. The core carries the Reading-mode heading link fix (#74), the template editor fix (#78), the command-palette focus fix (#65), Favorites on Home, per-note panel memory, and undo history that survives switching notes. The undo-file bridge methods stay unimplemented: that setting is desktop only. Gestures on every page (#75): the flick and pull-down hooks only accepted touches inside `.cm-editor, .prose-zen`, which Home, Tasks, Quick Notes, Tags, Files, Archive and Trash do not render. The pane section is now the surface, minus the tab strip, headers, side sheets and rows that already answer a finger. Edit/Read in the ••• sheet (#76, not reproduced): the entries were gated on core's content cache alone; the note-index lookup now counts too, so a cache hiccup cannot hide them while a note is on screen. The open database gets the same Rename / Move to… / Delete rows as its Browse row (#77), and Browse folders and databases get Move to… (requestMoveBrowseDirectory, public since core 2.52.0). Favorites (desktop #810): the ••• sheet and the long-press menu on note rows toggle the vault's favorites list (requestToggleNoteFavorite), hidden in Trash where the command refuses. mobile.css shows Home's Favorites section (note favorites only; folder favorites open the desktop tree) and keeps hiding Today; note-row-gestures reads the data-home-note-path that cores from 2.53 stamp on every Home row. The wikilink hover preview is hidden on coarse pointers: a tap in Reading mode synthesized mouse events that opened the card and nothing ever dismissed it (covered by a test). Folder vaults (desktop #813, Android half): SafFsPlugin.rename() splits into same-directory rename, cross-directory move with the same name, and cross-directory move with a new name, which renames in the source folder first (to the target name when free, else a hidden .zn-move-- temporary), moves, then applies the final name; each hop rolls back on failure and reports FOLDER_STATE_UNCERTAIN when the rollback itself fails. The Storage Access Framework's move keeps the source name, so the old "move, then rename" collided with an existing file in the target folder. A provider that redirects a rename to a "name (1)" variant is undone instead of silently accepted. Bridge: ZenAppInfo.engine carries navigator.userAgent for the About Version details block (desktop #814); the settings conflict report includes the parsed parked copy as cloud_settings so core's dialog can offer a per-section answer (desktop #816). Verified on the Android 15 emulator over existing data: Favorites toggled from both menus and written to .zennotes/vault.json, shown on Home with swipe and long-press, absent in Trash; a [[Note#Heading]] tap in Reading mode opened the target at the heading in Reading mode with no card left over and Back returned in Reading mode; gestures answered on every page with row swipes and headers left alone; Rename, Move to… and Delete of an open database verified on disk; folder-vault trash with a taken target name landed as "Untitled 2.md" and the .zn-move temporary appeared in logcat; per-note panels survived a force-stop; the Settings search finds no undo-history setting. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b29d-0b29-7669-9202-24bfe881e47a Co-authored-by: Amp --- README.md | 7 +- .../main/java/md/zennotes/SafFsPlugin.java | 142 ++++++++++++++---- package-lock.json | 30 ++-- package.json | 6 +- src/bridge/mobile-bridge.ts | 8 +- src/bridge/mobile-cloud-sync.ts | 37 +++-- src/ui-mobile/MobileDrawer.tsx | 24 ++- src/ui-mobile/MobileShell.tsx | 119 ++++++++++++--- src/ui-mobile/mobile-layout.test.ts | 12 ++ src/ui-mobile/mobile.css | 27 +++- src/ui-mobile/note-actions.tsx | 37 ++++- src/ui-mobile/note-row-gestures.ts | 8 +- tooling/check-core-boundary.mjs | 10 ++ vendor/zennotes/manifest.json | 32 ++-- ...app-core-2.51.0-core.h49d73b531d346192.tgz | Bin 3101734 -> 0 bytes ...app-core-2.53.0-core.h598c8d004c9228a3.tgz | Bin 0 -> 3150051 bytes ...ct-2.51.0-boundaries.h18d39d9887df8897.tgz | Bin 36509 -> 0 bytes ...ct-2.53.0-boundaries.h193dbe157c4e64d2.tgz | Bin 0 -> 37492 bytes ...in-2.51.0-boundaries.h18d39d9887df8897.tgz | Bin 256891 -> 0 bytes ...in-2.53.0-boundaries.h193dbe157c4e64d2.tgz | Bin 0 -> 260201 bytes 20 files changed, 384 insertions(+), 115 deletions(-) delete mode 100644 vendor/zennotes/zennotes-app-core-2.51.0-core.h49d73b531d346192.tgz create mode 100644 vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz delete mode 100644 vendor/zennotes/zennotes-bridge-contract-2.51.0-boundaries.h18d39d9887df8897.tgz create mode 100644 vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz delete mode 100644 vendor/zennotes/zennotes-shared-domain-2.51.0-boundaries.h18d39d9887df8897.tgz create mode 100644 vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz diff --git a/README.md b/README.md index ec00e72..9da8e30 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. No source checkout is used. The vendored set is the published desktop release -[core-2.51.0-core.h49d73b531d346192](https://github.com/ZenNotes/zennotes/releases/tag/core-2.51.0-core.h49d73b531d346192) -(desktop commit `8ff2cb86`, tag v2.51.0, clean tree). Run `npm run +[core-2.53.0-core.h598c8d004c9228a3](https://github.com/ZenNotes/zennotes/releases/tag/core-2.53.0-core.h598c8d004c9228a3) +(desktop commit `3a622639`, tag v2.53.0, clean tree). Run `npm run boundaries:check` to verify archives, installed versions, singleton -editor/React peers, and imports. +editor/React peers, and imports; it refuses an archive built from a dirty +upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out. ## Architecture diff --git a/android/app/src/main/java/md/zennotes/SafFsPlugin.java b/android/app/src/main/java/md/zennotes/SafFsPlugin.java index 2dbabf5..fa820ba 100644 --- a/android/app/src/main/java/md/zennotes/SafFsPlugin.java +++ b/android/app/src/main/java/md/zennotes/SafFsPlugin.java @@ -382,40 +382,17 @@ public void rename(PluginCall call) { int ti = to.lastIndexOf('/'); String fromDir = fi == -1 ? "" : from.substring(0, fi); String toDir = ti == -1 ? "" : to.substring(0, ti); + String fromName = baseName(from); + String targetName = baseName(to); if (fromDir.equals(toDir)) { - Uri renamed = DocumentsContract.renameDocument( - resolver(), docUri(tree, src.docId), baseName(to) - ); - if (renamed == null) throw new Exception("Rename refused"); + renameExactly(tree, src.docId, fromName, targetName); } else { Entry fromParent = resolve(tree, fromDir); String toParentId = ensureParentDirs(tree, to); - Uri moved = DocumentsContract.moveDocument( - resolver(), - docUri(tree, src.docId), - docUri(tree, fromParent.docId), - docUri(tree, toParentId) - ); - if (moved == null) throw new Exception("Move refused"); - String movedId = DocumentsContract.getDocumentId(moved); - String targetName = baseName(to); - if (!baseName(from).equals(targetName)) { - try { - if (DocumentsContract.renameDocument(resolver(), docUri(tree, movedId), targetName) == null) { - throw new Exception("Rename after move refused"); - } - } catch (Exception renameError) { - // A rename promise must not reject after silently changing parents. - try { - Uri restored = DocumentsContract.moveDocument(resolver(), docUri(tree, movedId), - docUri(tree, toParentId), docUri(tree, fromParent.docId)); - if (restored == null) throw new Exception("Rollback move refused"); - } catch (Exception rollbackError) { - throw new Exception("FOLDER_STATE_UNCERTAIN: Rename failed and could not be restored: " - + rollbackError.getMessage(), renameError); - } - throw renameError; - } + if (fromName.equals(targetName)) { + moveDocument(tree, src.docId, fromParent.docId, toParentId); + } else { + moveRenamed(tree, src.docId, fromParent.docId, toParentId, fromName, targetName); } } invalidateParent(tree, from); @@ -428,6 +405,111 @@ public void rename(PluginCall call) { } } + /** One provider move. The document keeps its display name. */ + private String moveDocument(Uri tree, String docId, String fromParentId, String toParentId) throws Exception { + Uri moved = DocumentsContract.moveDocument( + resolver(), docUri(tree, docId), docUri(tree, fromParentId), docUri(tree, toParentId) + ); + if (moved == null) throw new Exception("Move refused"); + return DocumentsContract.getDocumentId(moved); + } + + /** + * Rename and insist on the exact name. The platform file provider does not + * refuse a taken name: it quietly lands on a "name (1)" variant, so the + * caller would believe the rename succeeded while the file sits at a path + * nobody asked for. Such a rename is undone and reported instead. + */ + private String renameExactly(Uri tree, String docId, String currentName, String name) throws Exception { + Uri renamed = DocumentsContract.renameDocument(resolver(), docUri(tree, docId), name); + if (renamed == null) throw new Exception("Rename refused"); + String renamedId = DocumentsContract.getDocumentId(renamed); + String actual = displayName(renamed); + if (actual == null || actual.equals(name)) return renamedId; + try { + if (DocumentsContract.renameDocument(resolver(), docUri(tree, renamedId), currentName) == null) { + throw new Exception("Rollback rename refused"); + } + } catch (Exception rollbackError) { + throw new Exception("FOLDER_STATE_UNCERTAIN: \"" + name + "\" is taken and the file is now named \"" + + actual + "\": " + rollbackError.getMessage()); + } + throw new Exception("\"" + name + "\" already exists"); + } + + /** + * A move that also changes the name. moveDocument keeps the display name, + * so moving first parks the file at targetDir/fromName and fails with + * "Already exists" whenever an unrelated file holds that name there, even + * though the destination itself is free. Cloud sync hit this on every + * retry when the desktop trashed Untitled.md as "trash/Untitled 2.md" and + * the phone's trash still held an older Untitled.md (desktop #813). Take + * the name first, inside the source directory, then cross directories: the + * only path that has to be free is the one the caller asked for. When the + * source directory already holds the target name (in any letter case, the + * storage may fold case), travel under a hidden temporary name and take the + * final name after the move. + */ + private void moveRenamed(Uri tree, String docId, String fromParentId, String toParentId, + String fromName, String targetName) throws Exception { + Map siblings = listings.get(cacheKey(tree, fromParentId)); + if (siblings == null) siblings = listChildren(tree, fromParentId); + boolean targetNameTaken = false; + for (String sibling : siblings.keySet()) { + if (sibling.equalsIgnoreCase(targetName)) { + targetNameTaken = true; + break; + } + } + String travelName = targetNameTaken ? temporaryName(targetName) : targetName; + String travelId = renameExactly(tree, docId, fromName, travelName); + String movedId; + try { + movedId = moveDocument(tree, travelId, fromParentId, toParentId); + } catch (Exception moveError) { + // A rename promise must not reject after silently changing the name. + try { + if (DocumentsContract.renameDocument(resolver(), docUri(tree, travelId), fromName) == null) { + throw new Exception("Rollback rename refused"); + } + } catch (Exception rollbackError) { + throw new Exception("FOLDER_STATE_UNCERTAIN: Move failed and the name could not be restored: " + + rollbackError.getMessage(), moveError); + } + throw moveError; + } + if (travelName.equals(targetName)) return; + try { + renameExactly(tree, movedId, travelName, targetName); + } catch (Exception renameError) { + // A rename promise must not reject after silently changing parents. + try { + String restoredId = moveDocument(tree, movedId, toParentId, fromParentId); + if (DocumentsContract.renameDocument(resolver(), docUri(tree, restoredId), fromName) == null) { + throw new Exception("Rollback rename refused"); + } + } catch (Exception rollbackError) { + throw new Exception("FOLDER_STATE_UNCERTAIN: Rename failed and could not be restored: " + + rollbackError.getMessage(), renameError); + } + throw renameError; + } + } + + private static String temporaryName(String name) { + return ".zn-move-" + Long.toHexString(System.nanoTime()) + "-" + name; + } + + /** The provider's current display name for a document, or null when it cannot be read. */ + private String displayName(Uri doc) { + try (Cursor c = resolver().query(doc, new String[] { Document.COLUMN_DISPLAY_NAME }, null, null, null)) { + if (c != null && c.moveToFirst() && !c.isNull(0)) return c.getString(0); + } catch (Exception ignored) { + // An unverifiable rename is taken at its word rather than failed. + } + return null; + } + @PluginMethod public void copy(PluginCall call) { Uri tree = requireTree(call); diff --git a/package-lock.json b/package-lock.json index e164e89..44737c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.11.6", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.51.0-core.h49d73b531d346192.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.51.0-boundaries.h18d39d9887df8897.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.51.0-boundaries.h18d39d9887df8897.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.15", "function-plot": "^1.25.3", @@ -3669,9 +3669,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.51.0-core.h49d73b531d346192", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.51.0-core.h49d73b531d346192.tgz", - "integrity": "sha512-WaJLtt9Z0+0KNhMqwL+LAX9fG6JJVeZfHkJjUiL9WyHFBLLPGi0MumfSiGeKpWHabNE/pIdiP1RoU19kNyH7JA==", + "version": "2.53.0-core.h598c8d004c9228a3", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz", + "integrity": "sha512-OdDukylM9XIVAQZfjEvxqAbLDTBUd0HpIHggRQsyegFpHTbR5cOL3pTUdmv3k56f3S22SegMRqQCs1BQdoUJSg==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3699,8 +3699,8 @@ "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.51.0-boundaries.h18d39d9887df8897", - "@zennotes/shared-domain": "2.51.0-boundaries.h18d39d9887df8897", + "@zennotes/bridge-contract": "2.53.0-boundaries.h193dbe157c4e64d2", + "@zennotes/shared-domain": "2.53.0-boundaries.h193dbe157c4e64d2", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3744,18 +3744,18 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.51.0-boundaries.h18d39d9887df8897", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.51.0-boundaries.h18d39d9887df8897.tgz", - "integrity": "sha512-ib31p4wDQvcLj+DcoEi6mq0T+1aHXL7b1MOkWpBCtQ9DkiXkHLQj2zvB3U41zc9znWe2JIXgdM6IzHVhvtd7Vw==", + "version": "2.53.0-boundaries.h193dbe157c4e64d2", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "integrity": "sha512-HO/4BploqBPtJ4Lu4/Wg+YfXuUxHF8RIzqPaA6jAnh20p6zporqtiJbfjQAtu/nCPqeCXgXc3yk9fr/s0ehvKw==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.51.0-boundaries.h18d39d9887df8897", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.51.0-boundaries.h18d39d9887df8897.tgz", - "integrity": "sha512-uocbc9KXy/7XB7MtBJWEG4/8Sod5MJ27MyInQJbT52OJl0RSuvJRcXf4cfnfNvIwVg3pCX8CFFPdtVAoujg4xg==", + "version": "2.53.0-boundaries.h193dbe157c4e64d2", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "integrity": "sha512-Uk53Icfq7IFasrxNrJN+tfGFFb5FaOgbb99IehRAmJZgkXG3IDW+RXsO4Sh3E4EqwTJk/sq68DCVmc1SNgbYtQ==", "license": "MIT", "dependencies": { - "@zennotes/bridge-contract": "2.51.0-boundaries.h18d39d9887df8897", + "@zennotes/bridge-contract": "2.53.0-boundaries.h193dbe157c4e64d2", "lz-string": "^1.5.0" } }, diff --git a/package.json b/package.json index d4ae5e5..a1cfdff 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,9 @@ "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.3.2", "zustand": "^5.0.2", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.51.0-core.h49d73b531d346192.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.51.0-boundaries.h18d39d9887df8897.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.51.0-boundaries.h18d39d9887df8897.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz", "@lezer/common": "^1.5.2" }, "devDependencies": { diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 260a664..97b876f 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -380,7 +380,13 @@ function mobileAppInfo(): ZenAppInfo { description: 'ZenNotes for Android', homepage: 'https://zennotes.org', runtime: 'web', - hostKind: 'android' + hostKind: 'android', + // The WebView's user agent names the Android version, the device model + // and the Chrome build, the lines a bug report from a phone needs beside + // the app version (#814); nothing else here is guessed. + ...(typeof navigator !== 'undefined' && navigator.userAgent + ? { engine: navigator.userAgent } + : {}) } } diff --git a/src/bridge/mobile-cloud-sync.ts b/src/bridge/mobile-cloud-sync.ts index 15d2687..2e1cf71 100644 --- a/src/bridge/mobile-cloud-sync.ts +++ b/src/bridge/mobile-cloud-sync.ts @@ -173,9 +173,18 @@ export async function getMobileCloudSettingsConflict( ): Promise { const parked = await vault.fs.statOrNull(CLOUD_SYNC_SETTINGS_CONFLICT_PATH) if (parked?.type !== 'file') return null + const raw = await vault.fs.readTextOrNull(CLOUD_SYNC_SETTINGS_CONFLICT_PATH) + if (raw === null) return null + // The parsed copy lets the app show what differs and offer a per-section + // answer (desktop parity, #816). A copy that does not parse is still a + // pending question (the file is there, and sync will not touch vault.json + // until it is gone), so it is reported without the contents and the app + // asks whole-file. + const cloudSettings = parseParkedSettings(raw) return { path: CLOUD_SYNC_VAULT_SETTINGS_PATH, - cloud_path: CLOUD_SYNC_SETTINGS_CONFLICT_PATH + cloud_path: CLOUD_SYNC_SETTINGS_CONFLICT_PATH, + ...(cloudSettings ? { cloud_settings: cloudSettings } : {}) } } @@ -189,22 +198,28 @@ export async function resolveMobileCloudSettingsConflict( ): Promise { if (choice === 'cloud') { const raw = await vault.fs.readTextOrNull(CLOUD_SYNC_SETTINGS_CONFLICT_PATH) - let parsed: unknown = null - if (raw !== null) { - try { - parsed = JSON.parse(raw) - } catch { - parsed = null - } - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + const parsed = raw === null ? null : parseParkedSettings(raw) + if (!parsed) { throw new Error('The settings from the cloud could not be read, so nothing was changed.') } - await vault.setVaultSettings(parsed as Parameters[0]) + await vault.setVaultSettings( + parsed as unknown as Parameters[0] + ) } await vault.fs.deleteFile(CLOUD_SYNC_SETTINGS_CONFLICT_PATH).catch(() => {}) } +function parseParkedSettings(raw: string): Record | null { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + return parsed as Record +} + export async function listMobileCloudBackups(vault: MobileVault): Promise { return service.listBackups(hostVault(vault)) } diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index 23c542d..b9fdbe6 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -10,7 +10,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import ReactDOM from 'react-dom/client' import { getShellSnapshot, useShellSnapshot, setNoteSortOrder, type NoteSortOrder } from '@zennotes/app-core/shell' import { getBrowseSnapshot, useBrowseSnapshot, getBrowseDirectory, requestCreateBrowseFolder, - requestRenameBrowseFolder, requestRenameBrowseDatabase, requestDeleteBrowseDirectory } from '@zennotes/app-core/browse' + requestRenameBrowseFolder, requestRenameBrowseDatabase, requestMoveBrowseDirectory, + requestDeleteBrowseDirectory } 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' @@ -889,8 +890,8 @@ function MobileDrawerBody(props: { const [sortOpen, setSortOpen] = useState(false) // Long-pressing a row opens its action sheet — the phone's right-click // (Discord folder feedback). Notes open the shell-wide note sheet - // (note-actions.tsx, shared with app-core's lists); folders get - // Rename/Delete here. Prompts overlay the open drawer (Modal layers above + // (note-actions.tsx, shared with app-core's lists); folders and databases + // get Rename/Move/Delete here. Prompts overlay the open drawer (Modal layers above // z-49), so the drawer stays put and its list refreshes in place via the // vault change events. const [folderMenu, setFolderMenu] = useState<{ kind: 'folder' | 'database'; subpath: string; name: string; host: ReturnType } | null>(null) @@ -980,6 +981,15 @@ function MobileDrawerBody(props: { const newFolderHere = (): void => { void requestCreateBrowseFolder(captureMobileWorkspace(), path).catch(reportActionError) } + // Core keeps the leaf name (a database keeps its .base suffix) and carries + // tabs, folder icons, favorites and manual order to the new path. Folder + // pins are keyed by subpath and are left alone, as Rename leaves them: an + // orphaned pin never matches a row and is pruned on the next toggle. + const moveFolderFromDrawer = (subpath: string): void => { + const host = folderMenu?.host ?? captureMobileWorkspace() + setFolderMenu(null) + void requestMoveBrowseDirectory(host, subpath).catch(reportActionError) + } const deleteFolder = (subpath: string, _name: string): void => { const host = folderMenu?.host ?? captureMobileWorkspace() void requestDeleteBrowseDirectory(host, subpath).catch(reportActionError) @@ -1246,6 +1256,14 @@ function MobileDrawerBody(props: { Rename + +