From 01b5f2f269402172b98d09fa3a3cb320229b8fc2 Mon Sep 17 00:00:00 2001 From: Matt Derman Date: Sat, 30 May 2026 17:25:04 +0200 Subject: [PATCH 1/4] Ignore private folders and refresh sharing state Ignore private relay folders during sync and surface ignored remote entries so private folders are not recreated on other devices. Refresh remote sharing state after role/access changes and keep the new behavior tests in plaintext src/*.test.ts files because __tests__/** is git-crypt encrypted. --- src/RelayManager.ts | 41 +- src/SharedFolder.ts | 488 ++++++++++++++++++++--- src/components/ManageRemoteFolder.svelte | 19 + src/components/ManageSharedFolder.svelte | 17 + src/components/PluginSettings.svelte | 50 +++ src/ignoredFolderPolicy.test.ts | 112 ++++++ src/ignoredFolderPolicy.ts | 79 ++++ src/main.ts | 35 +- src/privateFolderIgnore.test.ts | 49 +++ src/privateFolderIgnore.ts | 27 ++ src/ui/FolderNav.ts | 6 +- src/ui/IgnoredRemoteEntriesModal.ts | 50 +++ 12 files changed, 904 insertions(+), 69 deletions(-) create mode 100644 src/ignoredFolderPolicy.test.ts create mode 100644 src/ignoredFolderPolicy.ts create mode 100644 src/privateFolderIgnore.test.ts create mode 100644 src/privateFolderIgnore.ts create mode 100644 src/ui/IgnoredRemoteEntriesModal.ts diff --git a/src/RelayManager.ts b/src/RelayManager.ts index f484d5ab..42b2aa0d 100644 --- a/src/RelayManager.ts +++ b/src/RelayManager.ts @@ -1484,6 +1484,7 @@ export class RelayManager extends HasLogging { policyManager?: IPolicyManager; _offLoginManager: Unsubscriber; private _isSubscribed = false; + private remoteAccessRefreshTimer: number | null = null; private pb: PocketBase | null; destroyed = false; @@ -1661,6 +1662,7 @@ export class RelayManager extends HasLogging { } logout() { + this.clearRemoteAccessRefreshTimer(); this.store?.clear(); this.user = undefined; this.store = undefined; @@ -1761,8 +1763,38 @@ export class RelayManager extends HasLogging { } else { this.store?.ingest(e.record); } + this.queueRemoteAccessRefresh(collectionName); }; + private queueRemoteAccessRefresh(collectionName: string): void { + if ( + collectionName !== "relay_roles" && + collectionName !== "shared_folder_roles" && + collectionName !== "shared_folders" && + collectionName !== "relays" + ) { + return; + } + if (this.remoteAccessRefreshTimer !== null) { + return; + } + this.remoteAccessRefreshTimer = window.setTimeout(() => { + this.remoteAccessRefreshTimer = null; + if (this.destroyed) return; + this.update().catch((error) => { + this.warn("failed to refresh remote access state", error); + }); + }, 250); + } + + private clearRemoteAccessRefreshTimer(): void { + if (this.remoteAccessRefreshTimer === null) { + return; + } + window.clearTimeout(this.remoteAccessRefreshTimer); + this.remoteAccessRefreshTimer = null; + } + async subscribe() { if ( !this.pb || @@ -1787,9 +1819,12 @@ export class RelayManager extends HasLogging { }, { name: "relay_invitations", expand: ["relay"] }, { name: "providers", expand: [] }, - { name: "relay_roles", expand: ["user", "relay"] }, + { name: "relay_roles", expand: ["user", "relay", "role"] }, { name: "shared_folders", expand: ["relay", "creator"] }, - { name: "shared_folder_roles", expand: ["user", "shared_folder"] }, + { + name: "shared_folder_roles", + expand: ["user", "shared_folder", "role"], + }, { name: "subscriptions", expand: ["user", "relay"] }, ]; @@ -1823,6 +1858,7 @@ export class RelayManager extends HasLogging { */ offline(): void { if (!this.pb) return; + this.clearRemoteAccessRefreshTimer(); this.pb.realtime.unsubscribe(); this._isSubscribed = false; } @@ -2289,6 +2325,7 @@ export class RelayManager extends HasLogging { destroy(): void { this.destroyed = true; + this.clearRemoteAccessRefreshTimer(); this._offLoginManager?.(); this._offLoginManager = null as any; this.pb?.cancelAllRequests(); diff --git a/src/SharedFolder.ts b/src/SharedFolder.ts index 71de33d6..8cf87fe4 100644 --- a/src/SharedFolder.ts +++ b/src/SharedFolder.ts @@ -33,6 +33,13 @@ import { BackgroundSync } from "./BackgroundSync"; import type { NamespacedSettings } from "./SettingsStorage"; import { RelayInstances, metrics } from "./debug"; import { LocalStorage } from "./LocalStorage"; +import { + classifyRenameSyncAction, + collectIgnoredRemoteEntries, + isContainedVaultPath, + isIgnoredVirtualPath, + type IgnoredRemoteEntry, +} from "./ignoredFolderPolicy"; import { SyncFolder, isSyncFolder } from "./SyncFolder"; import { isDocument } from "./Document"; import { SyncStore } from "./SyncStore"; @@ -68,6 +75,12 @@ import { generateHash } from "./hashing"; import { HSMStore, } from "./merge-hsm/persistence"; +import { + snapshotContains, + snapshotFromDoc, + snapshotFromUpdate, + snapshotsEqual, +} from "./merge-hsm/state-vectors"; import { trackPromise } from "./trackPromise"; import { RemoteActivityIndex, @@ -134,6 +147,7 @@ interface Noop extends Operation { } type OperationType = Create | Rename | Delete | Update | Upgrade | Noop; +type RemapLocalPromotionResult = "not-applicable" | "completed" | "deferred"; class Files extends ObservableSet { // Startup performance optimization @@ -194,6 +208,11 @@ export class SharedFolder extends HasProvider { private recordingBridge: E2ERecordingBridge; private _pendingKeyframeUpdates: Map = new Map(); private _pendingRemaps: Set = new Set(); + private _emptyRemoteRemapBackoff: Map = new Map(); private _pendingDownloads: Set = new Set(); private _pendingDownloadPromises: Map> = new Map(); @@ -226,6 +245,7 @@ export class SharedFolder extends HasProvider { private _settings: NamespacedSettings, private _hsmStore: HSMStore, timeProvider: TimeProvider, + private getIgnoredFolderNameSetting: () => string, relayId?: string, authoritative: boolean = false, remote?: RemoteSharedFolder, @@ -685,16 +705,26 @@ export class SharedFolder extends HasProvider { return match; } + private findLoadedDocumentAtPath(path: string): Document | null { + const file = this.fset.find((file) => file.path === path); + return file && isDocument(file) ? file : null; + } + private retryDeferredRemapForGuid(guid: string): void { const path = this.findCommittedPathByGuid(guid); if (!path || this._pendingRemaps.has(path)) return; const localGuid = this.syncStore.get(path); - if (!localGuid || localGuid === guid) return; + const localFile = localGuid ? this.files.get(localGuid) : null; + const localDocument = + localFile && isDocument(localFile) + ? localFile + : this.findLoadedDocumentAtPath(path); + const fromGuid = localDocument?.guid ?? localGuid; + if (!fromGuid || fromGuid === guid) return; - const localFile = this.files.get(localGuid); const committedMeta = this.syncStore.getCommittedMeta(path); - if (!localFile || !isDocument(localFile) || !isDocumentMeta(committedMeta)) { + if (!localDocument || !isDocumentMeta(committedMeta)) { return; } if (committedMeta.id !== guid) return; @@ -702,7 +732,7 @@ export class SharedFolder extends HasProvider { this._pendingRemaps.add(path); this.executeRemap({ path, - fromGuid: localGuid, + fromGuid, toGuid: guid, }).catch((e) => { this.warn(`[${path}] remap retry from update event failed`, e); @@ -1012,7 +1042,7 @@ export class SharedFolder extends HasProvider { if (types) { syncTFiles = syncTFiles.filter((tfile) => { if (tfile instanceof TFolder) return false; - const vpath = this.getVirtualPath(tfile.path); + const vpath = this.getSyncVirtualPath(tfile.path); const fileType = this.syncStore.typeRegistry.getTypeForPath(vpath); return types.includes(fileType); @@ -1022,7 +1052,7 @@ export class SharedFolder extends HasProvider { // Reserve GUIDs for new files before processing this.placeHold(syncTFiles); syncTFiles.forEach((tfile) => { - const vpath = this.getVirtualPath(tfile.path); + const vpath = this.getSyncVirtualPath(tfile.path); const guid = this.syncStore.get(vpath); const existing = guid ? this.files.get(guid) : undefined; if (existing) { @@ -1082,21 +1112,23 @@ export class SharedFolder extends HasProvider { } public isSyncableTFile(tfile: TAbstractFile): boolean { - const inFolder = this.checkPath(tfile.path); - const vpath = this.getVirtualPath(tfile.path); + if (!this.checkPath(tfile.path) || this.isIgnoredVaultPath(tfile.path)) { + return false; + } + + const vpath = this.getSyncVirtualPath(tfile.path); const isSupportedFileType = this.syncStore.canSync(vpath); // For folders, we only need to check if the sync store supports them // Extension preferences don't apply to folders if (tfile instanceof TFolder) { - return inFolder && isSupportedFileType; + return isSupportedFileType; } const isExtensionEnabled = this.syncSettingsManager.isExtensionEnabled(vpath); return ( - inFolder && isSupportedFileType && isExtensionEnabled && !this.isStorageBlockedTFile(tfile) @@ -1105,11 +1137,13 @@ export class SharedFolder extends HasProvider { public isStorageBlockedTFile(tfile: TAbstractFile): boolean { if (!(tfile instanceof TFile)) return false; - if (!this.checkPath(tfile.path)) return false; + if (!this.checkPath(tfile.path) || this.isIgnoredVaultPath(tfile.path)) { + return false; + } const quota = this.remote?.relay.storageQuota?.quota ?? this.storageQuota; if (quota !== 0) return false; return this.syncSettingsManager.requiresStorage( - this.getVirtualPath(tfile.path), + this.getSyncVirtualPath(tfile.path), ); } @@ -1494,6 +1528,271 @@ export class SharedFolder extends HasProvider { } } + private shouldBackOffEmptyRemoteRemap(path: string, guid: string): boolean { + const backoff = this._emptyRemoteRemapBackoff.get(path); + return !!backoff && backoff.guid === guid && backoff.nextAttemptAt > this.currentTime(); + } + + private recordEmptyRemoteRemapDeferred(path: string, guid: string): void { + const current = this._emptyRemoteRemapBackoff.get(path); + const attempts = current?.guid === guid ? current.attempts + 1 : 1; + const delayMs = Math.min(60000, 1000 * Math.pow(2, Math.min(attempts - 1, 6))); + this._emptyRemoteRemapBackoff.set(path, { + guid, + attempts, + nextAttemptAt: this.currentTime() + delayMs, + }); + } + + private clearEmptyRemoteRemapBackoff(path: string, guid?: string): void { + const current = this._emptyRemoteRemapBackoff.get(path); + if (!current) return; + if (guid && current.guid !== guid) return; + this._emptyRemoteRemapBackoff.delete(path); + } + + private async removeLocalDocumentIdentity( + path: string, + guid: string, + sameGuid: boolean, + ): Promise { + const existingFile = this.files.get(guid); + const existingHsm = existingFile && isDocument(existingFile) + ? existingFile.hsm + : null; + if (sameGuid) { + try { + await existingHsm?.resetLocalPersistenceForRebuild(); + } catch (e) { + this.warn(`[${path}] rebuild local cleanup failed`, e); + throw e; + } + await this._hsmStore.deleteState(guid); + } else { + try { + indexedDB.deleteDatabase(`${this.appId}-relay-doc-${guid}`); + } catch { /* best effort stale database cleanup */ } + const p = this._hsmStore.deleteState(guid).catch(() => {}); + trackAsyncCleanup(p); + } + + this.backgroundSync.cancelDocumentWork(guid); + + if (existingFile) { + this.files.delete(guid); + this.fset.delete(existingFile); + existingFile.cleanup(); + existingFile.destroy(); + } + } + + private replaceDocumentMetaIfCurrent( + path: string, + expectedGuid: string, + replacementGuid: string, + ): boolean { + let replaced = false; + this.ydoc.transact(() => { + const current = this.syncStore.getCommittedMeta(path); + if (!isDocumentMeta(current) || current.id !== expectedGuid) { + return; + } + this.syncStore.pendingUpload.delete(path); + this.syncStore.set(path, makeDocumentMeta(replacementGuid)); + replaced = true; + }, this); + return replaced; + } + + private cleanupReplacementDocument(guid: string, doc: Document): void { + if (this.files.get(guid) === doc) { + this.files.delete(guid); + } + this.fset.delete(doc); + doc.cleanup(); + doc.destroy(); + this.backgroundSync.cancelDocumentWork(guid); + try { + indexedDB.deleteDatabase(`${this.appId}-relay-doc-${guid}`); + } catch { /* best effort stale database cleanup */ } + const p = this._hsmStore.deleteState(guid).catch(() => {}); + trackAsyncCleanup(p); + } + + private async recoverEmptyRemoteRemap({ + path, + fromGuid, + emptyGuid, + }: { + path: string; + fromGuid: string; + emptyGuid: string; + }): Promise { + if (fromGuid === emptyGuid) return false; + + const fileForGuid = this.files.get(fromGuid); + const sourceFile = fileForGuid && isDocument(fileForGuid) + ? fileForGuid + : this.findLoadedDocumentAtPath(path); + if (!sourceFile || sourceFile.destroyed) { + this.warn(`[${path}] remap recovery skipped: local document is not loaded`); + return false; + } + const sourceGuid = sourceFile.guid; + if (sourceGuid === emptyGuid) return false; + + const committedMeta = this.syncStore.getCommittedMeta(path); + if (!isDocumentMeta(committedMeta) || committedMeta.id !== emptyGuid) { + this.log(`[${path}] remap recovery skipped: metadata changed`); + return false; + } + + const replacementGuid = uuidv4(); + const replacementDoc = this.getOrCreateDoc(replacementGuid, path); + this.files.set(replacementGuid, replacementDoc); + let metadataReplaced = false; + + try { + await replacementDoc.hsm?.initializeWithContent(); + await this.backgroundSync.enqueueUpload(replacementDoc); + + if (this.destroyed || replacementDoc.destroyed) { + this.log(`[${path}] remap recovery aborted: replacement document is stale`); + this.cleanupReplacementDocument(replacementGuid, replacementDoc); + return false; + } + + const replaced = this.replaceDocumentMetaIfCurrent( + path, + emptyGuid, + replacementGuid, + ); + if (!replaced) { + this.log( + `[${path}] remap recovery skipped: metadata no longer points at empty guid`, + ); + this.cleanupReplacementDocument(replacementGuid, replacementDoc); + return false; + } + metadataReplaced = true; + + await this.removeLocalDocumentIdentity(path, sourceGuid, false); + this.files.set(replacementGuid, replacementDoc); + this.fset.add(replacementDoc, true); + replacementDoc.hsm?.setRemoteDoc(replacementDoc.ensureRemoteDoc()); + this.clearEmptyRemoteRemapBackoff(path, emptyGuid); + await this.poll([replacementGuid]); + this.log( + `[${path}] remap recovery uploaded local content to replacement guid ${replacementGuid}`, + ); + return true; + } catch (e) { + this.warn(`[${path}] remap recovery failed`, e); + if (!metadataReplaced) { + this.cleanupReplacementDocument(replacementGuid, replacementDoc); + return false; + } + this.clearEmptyRemoteRemapBackoff(path, emptyGuid); + return true; + } + } + + private async promoteLocalDocForContainedRemoteRemap({ + path, + fromGuid, + toGuid, + updateBytes, + }: { + path: string; + fromGuid: string; + toGuid: string; + updateBytes: Uint8Array; + }): Promise { + if (fromGuid === toGuid) return "not-applicable"; + + const sourceFile = this.files.get(fromGuid); + if (!sourceFile || !isDocument(sourceFile)) return "not-applicable"; + + const sourceHsm = sourceFile.hsm; + if (!sourceHsm || sourceHsm.hasFork()) return "not-applicable"; + + try { + sourceHsm.ensureLocalDocForIdle(); + await sourceHsm.awaitPersistenceReady(); + } catch (e) { + this.warn(`[${path}] remap local promotion skipped: local persistence unavailable`, e); + return "not-applicable"; + } + + const localDoc = sourceHsm.getLocalDoc(); + if (!localDoc) return "not-applicable"; + + try { + const localSnapshot = snapshotFromDoc(localDoc); + const remoteSnapshot = snapshotFromUpdate(updateBytes); + if ( + snapshotsEqual(localSnapshot, remoteSnapshot) || + snapshotContains(remoteSnapshot, localSnapshot) + ) { + return "not-applicable"; + } + if (!snapshotContains(localSnapshot, remoteSnapshot)) { + return "not-applicable"; + } + } catch (e) { + this.warn(`[${path}] remap local promotion skipped: ancestry check failed`, e); + return "not-applicable"; + } + + const localUpdate = Y.encodeStateAsUpdate(localDoc); + const promotedDoc = this.getOrCreateDoc(toGuid, path); + this.files.set(toGuid, promotedDoc); + const isCurrentDoc = () => + !this.destroyed && !promotedDoc.destroyed && this.files.get(toGuid) === promotedDoc; + + try { + await promotedDoc.hsm?.initializeFromRemote(localUpdate); + const remoteDoc = promotedDoc.ensureRemoteDoc(); + Y.applyUpdate(remoteDoc, localUpdate, remoteDoc); + promotedDoc.hsm?.setRemoteDoc(remoteDoc); + + if (!isCurrentDoc()) { + this.log(`[${path}] remap local promotion deferred: promoted document is stale`); + return "deferred"; + } + + await this.backgroundSync.enqueueUpload(promotedDoc); + + const current = this.syncStore.getCommittedMeta(path); + if (!isDocumentMeta(current) || current.id !== toGuid) { + this.log(`[${path}] remap local promotion deferred: metadata changed`); + this.cleanupReplacementDocument(toGuid, promotedDoc); + return "deferred"; + } + + await this.removeLocalDocumentIdentity(path, fromGuid, false); + this.syncStore.pendingUpload.delete(path); + this.files.set(toGuid, promotedDoc); + this.fset.add(promotedDoc, true); + + if (promotedDoc.hsm && !promotedDoc.hsm.state.lca) { + await promotedDoc.hsm.awaitIdle(); + const diskState = await promotedDoc.readDiskContent(); + await promotedDoc.hsm.bootstrapLCAFromDisk(diskState); + } + await this.poll([toGuid]); + + this.log( + `[${path}] remap promoted local CRDT into canonical guid ${toGuid}`, + ); + return "completed"; + } catch (e) { + this.warn(`[${path}] remap local promotion failed`, e); + this.cleanupReplacementDocument(toGuid, promotedDoc); + return "deferred"; + } + } + /** * Swap or rebuild a document's local CRDT identity. Called when the folder's * meta CRDT resolves a path to a GUID that differs from the one we enrolled @@ -1526,6 +1825,10 @@ export class SharedFolder extends HasProvider { this.log(`[${path}] ${operation} deferred: folder offline`); return; } + if (!sameGuid && this.shouldBackOffEmptyRemoteRemap(path, toGuid)) { + recordOperationTerminal("deferred"); + return; + } let updateBytes: Uint8Array | undefined; try { @@ -1537,10 +1840,40 @@ export class SharedFolder extends HasProvider { } if (!updateBytes) { + if (!sameGuid) { + const recovered = await this.recoverEmptyRemoteRemap({ + path, + fromGuid, + emptyGuid: toGuid, + }); + if (recovered) { + recordOperationTerminal("completed"); + return; + } + } + this.recordEmptyRemoteRemapDeferred(path, toGuid); recordOperationTerminal("deferred"); this.log(`[${path}] ${operation} deferred: server has guid but no content yet`); return; } + this.clearEmptyRemoteRemapBackoff(path, toGuid); + + if (!sameGuid) { + const promoted = await this.promoteLocalDocForContainedRemoteRemap({ + path, + fromGuid, + toGuid, + updateBytes, + }); + if (promoted === "completed") { + recordOperationTerminal("completed"); + return; + } + if (promoted === "deferred") { + recordOperationTerminal("deferred"); + return; + } + } if (this.destroyed) { recordOperationTerminal("deferred"); @@ -1549,35 +1882,7 @@ export class SharedFolder extends HasProvider { } try { - const existingFile = this.files.get(fromGuid); - const existingHsm = existingFile && isDocument(existingFile) - ? existingFile.hsm - : null; - if (sameGuid) { - try { - await existingHsm?.resetLocalPersistenceForRebuild(); - } catch (e) { - this.warn(`[${path}] rebuild local cleanup failed`, e); - throw e; - } - await this._hsmStore.deleteState(fromGuid); - } else { - try { - indexedDB.deleteDatabase(`${this.appId}-relay-doc-${fromGuid}`); - } catch { /* best effort stale database cleanup */ } - const p = this._hsmStore.deleteState(fromGuid).catch(() => {}); - trackAsyncCleanup(p); - } - - this.backgroundSync.cancelDocumentWork(fromGuid); - - if (existingFile) { - this.files.delete(fromGuid); - this.fset.delete(existingFile); - existingFile.cleanup(); - existingFile.destroy(); - } - + await this.removeLocalDocumentIdentity(path, fromGuid, sameGuid); this.syncStore.pendingUpload.delete(path); const newDoc = this.getOrCreateDoc(toGuid, path); @@ -1659,6 +1964,10 @@ export class SharedFolder extends HasProvider { if (!file) { const localGuid = this.syncStore.get(path); const localFile = localGuid ? this.files.get(localGuid) : null; + const localDocument = + localFile && isDocument(localFile) + ? localFile + : this.findLoadedDocumentAtPath(path); if (localGuid && localFile && isSyncFile(localFile) && isSyncFileMeta(meta)) { const promise = this.remapIfHashMatches( @@ -1671,13 +1980,13 @@ export class SharedFolder extends HasProvider { return { op: "update", path, promise }; } - if (localGuid && localGuid !== guid && isDocumentMeta(meta)) { + if (localDocument && localDocument.guid !== guid && isDocumentMeta(meta)) { return { op: "update", path, promise: this.executeRemap({ path, - fromGuid: localGuid, + fromGuid: localDocument.guid, toGuid: guid, }), }; @@ -1773,7 +2082,9 @@ export class SharedFolder extends HasProvider { // If the file is in the shared folder and not in the map, move it to the Trash const isSyncableFile = this.isSyncableTFile(file); const fileInFolder = this.checkPath(file.path); - const vpath = this.getVirtualPath(file.path); + if (!fileInFolder) return; + + const vpath = this.getSyncVirtualPath(file.path); const fileInMap = remotePaths.has(vpath); const filePending = this.pendingUpload.has(vpath); const synced = this._provider?.synced && this._persistence?.synced; @@ -1800,6 +2111,7 @@ export class SharedFolder extends HasProvider { private getDesiredRemotePaths(): Set { const paths = new Set(); this.syncStore.forEachWithPending((_meta, path) => { + if (this.isIgnoredVirtualPath(path)) return; paths.add(path); }); return expandDesiredRemotePaths(paths); @@ -1812,6 +2124,10 @@ export class SharedFolder extends HasProvider { types: SyncType[], ) { syncStore.forEachWithPending((meta, path) => { + if (this.isIgnoredVirtualPath(path)) { + return; + } + this._assertNamespacing(path); if (meta && types.contains(meta.type)) { ops.push( @@ -2019,7 +2335,7 @@ export class SharedFolder extends HasProvider { } checkPath(path: string): boolean { - return path.startsWith(this.path + sep); + return isContainedVaultPath(path, this.path); } getVirtualPath(path: string): string { @@ -2029,6 +2345,46 @@ export class SharedFolder extends HasProvider { return vPath; } + isIgnoredVirtualPath(vpath: string): boolean { + return isIgnoredVirtualPath(vpath, this.getIgnoredFolderName()); + } + + getIgnoredFolderName(): string { + return this.getIgnoredFolderNameSetting(); + } + + isIgnoredVaultPath(path: string): boolean { + if (!this.checkPath(path)) return false; + return this.isIgnoredVirtualPath(path.slice(this.path.length + sep.length)); + } + + getSyncVirtualPath(path: string): string { + const vpath = this.getVirtualPath(path); + if (this.isIgnoredVirtualPath(vpath)) { + throw new Error("Path is ignored by local sync rules: " + path); + } + return vpath; + } + + getIgnoredRemoteEntries(): IgnoredRemoteEntry[] { + const entries: [string, Meta][] = []; + this.syncStore.forEach((meta, path) => { + entries.push([path, meta]); + }); + return collectIgnoredRemoteEntries(entries, this.getIgnoredFolderName()); + } + + cleanupIgnoredRemoteEntries(entries = this.getIgnoredRemoteEntries()): number { + if (entries.length === 0) return 0; + for (const entry of entries) { + this.backgroundSync.cancelDocumentWork(entry.guid); + } + this.deleteFiles(entries.map((entry) => entry.path)); + this.syncStore.commit(); + this.fset.update(); + return entries.length; + } + getTFile(file: IFile): TFile | null { const maybeTFile = this.vault.getAbstractFileByPath( this.getPath(file.path), @@ -2192,7 +2548,7 @@ export class SharedFolder extends HasProvider { } getFile(tfile: TAbstractFile, update = true): IFile | null { - const vpath = this.getVirtualPath(tfile.path); + const vpath = this.getSyncVirtualPath(tfile.path); const guid = this.syncStore.get(vpath); // If file exists in sync store, use its metadata type to determine what to return @@ -2245,7 +2601,7 @@ export class SharedFolder extends HasProvider { const newDocs: string[] = []; this.ydoc.transact(() => { newFiles.forEach((file) => { - const vpath = this.getVirtualPath(file.path); + const vpath = this.getSyncVirtualPath(file.path); if (this.isPendingDelete(vpath)) { this.log("skipping place hold for pending delete", vpath); return; @@ -2703,7 +3059,7 @@ export class SharedFolder extends HasProvider { } uploadFile(tfile: TAbstractFile, update = true): IFile { - const vpath = this.getVirtualPath(tfile.path); + const vpath = this.getSyncVirtualPath(tfile.path); if (tfile instanceof TFolder) { return this.getSyncFolder(vpath, update); } else if (tfile instanceof TFile) { @@ -2818,23 +3174,27 @@ export class SharedFolder extends HasProvider { renameFile(tfile: TAbstractFile, oldPath: string) { const newPath = tfile.path; - let newVPath = ""; - let oldVPath = ""; - try { - newVPath = this.getVirtualPath(newPath); - } catch { - this.log("Moving out of shared folder"); - } - try { - oldVPath = this.getVirtualPath(oldPath); - } catch { - this.log("Moving in from outside of shared folder"); - } - - if (!newVPath && !oldVPath) { + const oldInSharedFolder = this.checkPath(oldPath); + const newInSharedFolder = this.checkPath(newPath); + const oldIgnored = oldInSharedFolder && this.isIgnoredVaultPath(oldPath); + const newIgnored = newInSharedFolder && this.isIgnoredVaultPath(newPath); + const action = classifyRenameSyncAction({ + oldInSharedFolder, + oldIgnored, + newInSharedFolder, + newIgnored, + }); + const oldVPath = oldInSharedFolder && !oldIgnored + ? this.getSyncVirtualPath(oldPath) + : ""; + const newVPath = newInSharedFolder && !newIgnored + ? this.getSyncVirtualPath(newPath) + : ""; + + if (action === "ignore") { // not related to shared folders return; - } else if (!oldVPath) { + } else if (action === "upload") { // if this was moved from outside the shared folder context, we need to create a live doc this.assertPath(newPath); if (!this.syncStore.canSync(newVPath)) return; @@ -2845,7 +3205,7 @@ export class SharedFolder extends HasProvider { const guid = this.syncStore.get(oldVPath); if (!guid) return; const file = this.files.get(guid); - if (!newVPath) { + if (action === "remove-sync-metadata") { // moving out of shared folder.. destroy the live doc. this.ydoc.transact(() => { this.syncStore.delete(oldVPath); diff --git a/src/components/ManageRemoteFolder.svelte b/src/components/ManageRemoteFolder.svelte index f2ce2501..8dc59e33 100644 --- a/src/components/ManageRemoteFolder.svelte +++ b/src/components/ManageRemoteFolder.svelte @@ -31,6 +31,7 @@ import { Check, Edit } from "lucide-svelte"; import { UserSelectModal } from "src/ui/UserSelectModal"; import { handleServerError } from "src/utils/toastStore"; + import { IgnoredRemoteEntriesModal } from "src/ui/IgnoredRemoteEntriesModal"; export let plugin: Live; export let remoteFolder: RemoteSharedFolder; export let sharedFolders: SharedFolders; @@ -53,6 +54,7 @@ .items() .find((folder) => folder.guid === remoteFolder.guid); }); + $: ignoredRemoteEntries = $folderStore?.getIgnoredRemoteEntries() || []; // Dynamic role loading for forwards compatibility const availableRoles = derived([plugin.relayManager.roles], ([$roles]) => { @@ -220,6 +222,12 @@ } } + function handleReviewIgnoredRemoteEntries() { + if ($folderStore) { + new IgnoredRemoteEntriesModal(plugin.app, $folderStore).open(); + } + } + async function handleDeleteRemote() { try { await plugin.relayManager.deleteRemote(remoteFolder); @@ -632,6 +640,17 @@ {/if} {#if $folderStore} + {#if ignoredRemoteEntries.length > 0} + + + + {/if} + + {#if ignoredRemoteEntries.length > 0} + + + + {/if} + + + + + + + {#if !ignoredFolderNameValid} +
+ Use one folder name only, without slashes. +
+ {/if} +
+
{/if} diff --git a/src/ignoredFolderPolicy.test.ts b/src/ignoredFolderPolicy.test.ts new file mode 100644 index 00000000..f8828460 --- /dev/null +++ b/src/ignoredFolderPolicy.test.ts @@ -0,0 +1,112 @@ +import { + classifyRenameSyncAction, + collectIgnoredRemoteEntries, + isContainedVaultPath, + isIgnoredVaultPath, + isIgnoredVirtualPath, +} from "./ignoredFolderPolicy"; +import { + SyncType, + makeCanvasMeta, + makeDocumentMeta, + makeFileMeta, + makeFolderMeta, +} from "./SyncTypes"; + +describe("ignored folder policy", () => { + test("matches exact ignored virtual path segments", () => { + expect(isIgnoredVirtualPath("_private")).toBe(true); + expect(isIgnoredVirtualPath("folder/_private/note.md")).toBe(true); + expect(isIgnoredVirtualPath("/_private/note.md")).toBe(true); + expect(isIgnoredVirtualPath("folder\\_private\\note.md")).toBe(true); + expect(isIgnoredVirtualPath("_private.md")).toBe(false); + expect(isIgnoredVirtualPath("_Private")).toBe(false); + expect(isIgnoredVirtualPath("my_private")).toBe(false); + }); + + test("supports custom ignored folder names", () => { + expect(isIgnoredVirtualPath("folder/_secret/note.md", "_secret")).toBe(true); + expect(isIgnoredVirtualPath("folder/_private/note.md", "_secret")).toBe(false); + }); + + test("keeps shared folder containment separate from ignored-path checks", () => { + expect(isContainedVaultPath("03-impression/_private/note.md", "03-impression")).toBe( + true, + ); + expect(isIgnoredVaultPath("03-impression/_private/note.md", "03-impression")).toBe( + true, + ); + expect(isIgnoredVaultPath("03-impression/public/note.md", "03-impression")).toBe( + false, + ); + }); + + test.each([ + [ + "remove-sync-metadata", + { + oldInSharedFolder: true, + oldIgnored: false, + newInSharedFolder: true, + newIgnored: true, + }, + ], + [ + "upload", + { + oldInSharedFolder: true, + oldIgnored: true, + newInSharedFolder: true, + newIgnored: false, + }, + ], + [ + "move-sync-metadata", + { + oldInSharedFolder: true, + oldIgnored: false, + newInSharedFolder: true, + newIgnored: false, + }, + ], + [ + "ignore", + { + oldInSharedFolder: true, + oldIgnored: true, + newInSharedFolder: true, + newIgnored: true, + }, + ], + ] as const)("classifies rename as %s", (expected, input) => { + expect(classifyRenameSyncAction(input)).toBe(expected); + }); + + test("collects ignored remote metadata and sorts children before parents", () => { + const entries = collectIgnoredRemoteEntries([ + ["_private", makeFolderMeta("folder-guid")], + ["_private/note.md", makeDocumentMeta("doc-guid")], + ["_private/board.canvas", makeCanvasMeta("canvas-guid")], + [ + "_private/assets/image.png", + makeFileMeta(SyncType.Image, "image-guid", "image/png", "hash", 1), + ], + ["_private.md", makeDocumentMeta("public-guid")], + ["_Private/note.md", makeDocumentMeta("case-guid")], + ["my_private/note.md", makeDocumentMeta("prefix-guid")], + ]); + + expect(entries.map((entry) => entry.path)).toEqual([ + "_private/assets/image.png", + "_private/board.canvas", + "_private/note.md", + "_private", + ]); + expect(entries.map((entry) => entry.guid)).toEqual([ + "image-guid", + "canvas-guid", + "doc-guid", + "folder-guid", + ]); + }); +}); diff --git a/src/ignoredFolderPolicy.ts b/src/ignoredFolderPolicy.ts new file mode 100644 index 00000000..c61105ff --- /dev/null +++ b/src/ignoredFolderPolicy.ts @@ -0,0 +1,79 @@ +import { sep } from "path-browserify"; +import type { Meta } from "./SyncTypes"; +import { + DEFAULT_IGNORED_FOLDER_NAME, + pathContainsIgnoredFolderSegment, +} from "./privateFolderIgnore"; + +export type RenameSyncAction = "ignore" | "remove-sync-metadata" | "upload" | "move-sync-metadata"; + +export interface IgnoredRemoteEntry { + path: string; + guid: string; + type: string; +} + +export function isIgnoredVirtualPath( + vpath: string, + ignoredFolderName = DEFAULT_IGNORED_FOLDER_NAME, +): boolean { + return pathContainsIgnoredFolderSegment(vpath, ignoredFolderName); +} + +export function isIgnoredVaultPath( + vaultPath: string, + sharedFolderPath: string, + ignoredFolderName = DEFAULT_IGNORED_FOLDER_NAME, +): boolean { + if (!isContainedVaultPath(vaultPath, sharedFolderPath)) return false; + return isIgnoredVirtualPath( + vaultPath.slice(sharedFolderPath.length + sep.length), + ignoredFolderName, + ); +} + +export function isContainedVaultPath( + vaultPath: string, + sharedFolderPath: string, +): boolean { + return vaultPath.startsWith(sharedFolderPath + sep); +} + +export function classifyRenameSyncAction(input: { + oldInSharedFolder: boolean; + oldIgnored: boolean; + newInSharedFolder: boolean; + newIgnored: boolean; +}): RenameSyncAction { + const oldSyncable = input.oldInSharedFolder && !input.oldIgnored; + const newSyncable = input.newInSharedFolder && !input.newIgnored; + + if (oldSyncable && newSyncable) return "move-sync-metadata"; + if (oldSyncable && !newSyncable) return "remove-sync-metadata"; + if (!oldSyncable && newSyncable) return "upload"; + return "ignore"; +} + +export function collectIgnoredRemoteEntries( + entries: Iterable<[string, Meta]>, + ignoredFolderName = DEFAULT_IGNORED_FOLDER_NAME, +): IgnoredRemoteEntry[] { + return Array.from(entries) + .filter(([path]) => isIgnoredVirtualPath(path, ignoredFolderName)) + .map(([path, meta]) => ({ + path, + guid: meta.id, + type: meta.type, + })) + .sort(compareDeepestPathFirst); +} + +export function compareDeepestPathFirst( + a: Pick, + b: Pick, +): number { + const aDepth = a.path.split(/[\\/]+/).filter(Boolean).length; + const bDepth = b.path.split(/[\\/]+/).filter(Boolean).length; + if (aDepth !== bDepth) return bDepth - aDepth; + return a.path.localeCompare(b.path); +} diff --git a/src/main.ts b/src/main.ts index 1d8789a2..5c8cb96c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -91,6 +91,10 @@ import { setPluginRequestConfig, } from "./customFetch"; import { RelayDebugAPI } from "./RelayDebugAPI"; +import { + DEFAULT_IGNORED_FOLDER_NAME, + normalizeIgnoredFolderName, +} from "./privateFolderIgnore"; interface DebugSettings { debugging: boolean; @@ -102,6 +106,7 @@ const DEFAULT_DEBUG_SETTINGS: DebugSettings = { interface RelaySettings extends FeatureFlags, DebugSettings { sharedFolders: SharedFolderSettings[]; + ignoredFolderName: string; release: ReleaseSettings; endpoints: EndpointSettings; } @@ -111,6 +116,7 @@ const DEFAULT_SETTINGS: RelaySettings = { channel: "stable", }, sharedFolders: [], + ignoredFolderName: DEFAULT_IGNORED_FOLDER_NAME, endpoints: {}, ...FeatureFlagDefaults, ...DEFAULT_DEBUG_SETTINGS, @@ -173,6 +179,24 @@ export default class Live extends Plugin { private _hsmStore!: HSMStore; promises = new PromiseTracker(); + getIgnoredFolderName(): string { + return normalizeIgnoredFolderName(this.settings?.get()?.ignoredFolderName); + } + + async setIgnoredFolderName(name: string): Promise { + const ignoredFolderName = normalizeIgnoredFolderName(name); + await this.settings.update((settings) => ({ + ...settings, + ignoredFolderName, + })); + this.sharedFolders?.forEach((folder) => { + void folder.syncFileTree().catch((error) => { + this.warn("Failed to resync after ignored folder setting changed", error); + }); + }); + this.folderNavDecorations?.refresh(); + } + enableDebugging(save?: boolean) { setDebugging(true); console.warn("RelayInstances", RelayInstances); @@ -266,6 +290,9 @@ export default class Live extends Plugin { if (!folder) { continue; } + if (folder.isIgnoredVaultPath(event.path)) { + continue; + } const vpath = folder.getVirtualPath(event.path); if (folder.isPendingDelete(vpath)) { continue; @@ -936,7 +963,10 @@ export default class Live extends Plugin { } } else if (file instanceof TFile) { const folder = this.sharedFolders.lookup(file.path); - const ifile = folder?.getFile(file); + const ifile = + folder && !folder.isIgnoredVaultPath(file.path) + ? folder.getFile(file) + : null; if (ifile && isSyncFile(ifile)) { menu.addItem((item) => { item @@ -1034,6 +1064,7 @@ export default class Live extends Plugin { folderSettings, this._hsmStore, this.timeProvider, + () => this.getIgnoredFolderName(), relayId, authoritative, remote, @@ -1207,6 +1238,7 @@ export default class Live extends Plugin { // NOTE: this is called on every file at startup... const folder = this.sharedFolders.lookup(tfile.path); if (folder) { + if (folder.isIgnoredVaultPath(tfile.path)) return; const newDocs = folder.placeHold([tfile]); if (newDocs.length > 0) { folder.uploadFile(tfile); @@ -1261,6 +1293,7 @@ export default class Live extends Plugin { this.app.vault.on("modify", async (tfile) => { const folder = this.sharedFolders.lookup(tfile.path); if (folder) { + if (folder.isIgnoredVaultPath(tfile.path)) return; vaultLog("Modify", tfile.path); const file = folder.proxy.getFile(tfile); if (file && isSyncFile(file)) { diff --git a/src/privateFolderIgnore.test.ts b/src/privateFolderIgnore.test.ts new file mode 100644 index 00000000..556e99b9 --- /dev/null +++ b/src/privateFolderIgnore.test.ts @@ -0,0 +1,49 @@ +import { + DEFAULT_IGNORED_FOLDER_NAME, + isValidIgnoredFolderName, + normalizeIgnoredFolderName, + pathContainsIgnoredFolderSegment, +} from "./privateFolderIgnore"; + +describe("pathContainsIgnoredFolderSegment", () => { + test.each([ + "_private", + "folder/_private", + "folder/_private/note.md", + "/_private/note.md", + "folder\\_private\\note.md", + ])("matches ignored folder segment in %s", (path) => { + expect(pathContainsIgnoredFolderSegment(path)).toBe(true); + }); + + test.each(["_private.md", "not_private", "_Private", "folder/my_private/note.md"])( + "does not match non-private folder segment in %s", + (path) => { + expect(pathContainsIgnoredFolderSegment(path)).toBe(false); + }, + ); + + test("uses custom ignored folder name", () => { + expect(pathContainsIgnoredFolderSegment("folder/_secret/note.md", "_secret")).toBe( + true, + ); + expect(pathContainsIgnoredFolderSegment("folder/_private/note.md", "_secret")).toBe( + false, + ); + }); + + test.each(["", " ", "nested/folder", "nested\\folder"])( + "normalizes invalid setting %s to default", + (name) => { + expect(normalizeIgnoredFolderName(name)).toBe(DEFAULT_IGNORED_FOLDER_NAME); + }, + ); + + test("validates user-provided ignored folder names", () => { + expect(isValidIgnoredFolderName("_private")).toBe(true); + expect(isValidIgnoredFolderName(" _secret ")).toBe(true); + expect(isValidIgnoredFolderName("nested/folder")).toBe(false); + expect(isValidIgnoredFolderName("nested\\folder")).toBe(false); + expect(isValidIgnoredFolderName(" ")).toBe(false); + }); +}); diff --git a/src/privateFolderIgnore.ts b/src/privateFolderIgnore.ts new file mode 100644 index 00000000..c80b78a8 --- /dev/null +++ b/src/privateFolderIgnore.ts @@ -0,0 +1,27 @@ +"use strict"; + +export const DEFAULT_IGNORED_FOLDER_NAME = "_private"; + +export function isValidIgnoredFolderName(name: string): boolean { + const trimmed = name.trim(); + return !!trimmed && !/[\\/]/.test(trimmed); +} + +export function normalizeIgnoredFolderName(name?: string): string { + const trimmed = name?.trim(); + if (!trimmed || !isValidIgnoredFolderName(trimmed)) { + return DEFAULT_IGNORED_FOLDER_NAME; + } + return trimmed; +} + +export function pathContainsIgnoredFolderSegment( + path: string, + ignoredFolderName = DEFAULT_IGNORED_FOLDER_NAME, +): boolean { + const ignoredSegment = normalizeIgnoredFolderName(ignoredFolderName); + return path + .split(/[\\/]+/) + .filter(Boolean) + .includes(ignoredSegment); +} diff --git a/src/ui/FolderNav.ts b/src/ui/FolderNav.ts index 5859f882..a9f01d1e 100644 --- a/src/ui/FolderNav.ts +++ b/src/ui/FolderNav.ts @@ -321,6 +321,7 @@ class QueueWatcherVisitor extends BaseVisitor { sharedFolder && sharedFolder.ready && sharedFolder.checkPath(file.path) && + !sharedFolder.isIgnoredVaultPath(file.path) && Document.checkExtension(file.path) ) { return ( @@ -470,6 +471,7 @@ class NotSyncedPillVisitor extends BaseVisitor { if ( sharedFolder && sharedFolder.checkPath(file.path) && + !sharedFolder.isIgnoredVaultPath(file.path) && (sharedFolder.isStorageBlockedTFile(file) || !sharedFolder.isSyncableTFile(file)) ) { @@ -538,7 +540,7 @@ class FileStatusVisitor extends BaseVisitor { ): DocumentStatus | null { if (sharedFolder) { try { - const vpath = sharedFolder.getVirtualPath(file.path); + const vpath = sharedFolder.getSyncVirtualPath(file.path); const guid = sharedFolder.syncStore.get(vpath); if (!guid) return null; const document = sharedFolder.files.get(guid); @@ -605,7 +607,7 @@ class FileConflictVisitor extends BaseVisitor { Document.checkExtension(file.path) ) { try { - const vpath = sharedFolder.getVirtualPath(file.path); + const vpath = sharedFolder.getSyncVirtualPath(file.path); const guid = sharedFolder.syncStore.get(vpath); if (!guid) { if (storage) storage.destroy(); diff --git a/src/ui/IgnoredRemoteEntriesModal.ts b/src/ui/IgnoredRemoteEntriesModal.ts new file mode 100644 index 00000000..14210f20 --- /dev/null +++ b/src/ui/IgnoredRemoteEntriesModal.ts @@ -0,0 +1,50 @@ +import { App, Modal, Notice } from "obsidian"; +import type { SharedFolder } from "../SharedFolder"; +import type { IgnoredRemoteEntry } from "../ignoredFolderPolicy"; + +export class IgnoredRemoteEntriesModal extends Modal { + private entries: IgnoredRemoteEntry[]; + + constructor( + app: App, + private sharedFolder: SharedFolder, + ) { + super(app); + this.entries = sharedFolder.getIgnoredRemoteEntries(); + this.setTitle("Ignored remote entries"); + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("p", { + text: `${this.entries.length} remote entr${this.entries.length === 1 ? "y" : "ies"} match the ignored folder name "${this.sharedFolder.getIgnoredFolderName()}". Cleanup removes Relay metadata only. Local files are not deleted, trashed, moved, or rewritten.`, + }); + + const list = contentEl.createEl("ul"); + for (const entry of this.entries) { + list.createEl("li", { + text: `${entry.path} (${entry.type})`, + }); + } + + const controls = contentEl.createDiv({ cls: "modal-button-container" }); + const cancel = controls.createEl("button", { text: "Cancel" }); + cancel.onClickEvent(() => this.close()); + + const clean = controls.createEl("button", { + text: "Remove Relay metadata", + cls: "mod-destructive", + }); + clean.onClickEvent(() => { + const removed = this.sharedFolder.cleanupIgnoredRemoteEntries(this.entries); + new Notice(`Removed ${removed} ignored remote entr${removed === 1 ? "y" : "ies"}.`); + this.close(); + }); + } + + onClose() { + this.contentEl.empty(); + } +} From 7be808bb734cce476a0b5d4119cad9bde91f85f1 Mon Sep 17 00:00:00 2001 From: Matt Derman Date: Sun, 31 May 2026 16:49:28 +0200 Subject: [PATCH 2/4] Use .relayignore for folder sync exclusions --- src/SharedFolder.ts | 101 ++++++++++++++--- src/components/ManageRemoteFolder.svelte | 19 ---- src/components/ManageSharedFolder.svelte | 18 +--- src/components/PluginSettings.svelte | 50 --------- src/ignoredFolderPolicy.test.ts | 65 ++++++----- src/ignoredFolderPolicy.ts | 37 +++++-- src/main.ts | 132 ++++++++++++++++++----- src/privateFolderIgnore.test.ts | 55 ++++------ src/privateFolderIgnore.ts | 40 +++---- src/ui/IgnoredRemoteEntriesModal.ts | 7 +- 10 files changed, 299 insertions(+), 225 deletions(-) diff --git a/src/SharedFolder.ts b/src/SharedFolder.ts index 8cf87fe4..5b444f1b 100644 --- a/src/SharedFolder.ts +++ b/src/SharedFolder.ts @@ -36,10 +36,16 @@ import { LocalStorage } from "./LocalStorage"; import { classifyRenameSyncAction, collectIgnoredRemoteEntries, + findIgnoredRootForVirtualPath, isContainedVaultPath, isIgnoredVirtualPath, type IgnoredRemoteEntry, } from "./ignoredFolderPolicy"; +import { + RELAY_IGNORE_FILE_NAME, + markerOwnerPath, + normalizeVirtualPath, +} from "./privateFolderIgnore"; import { SyncFolder, isSyncFolder } from "./SyncFolder"; import { isDocument } from "./Document"; import { SyncStore } from "./SyncStore"; @@ -197,6 +203,7 @@ export class SharedFolder extends HasProvider { private storageQuota?: number; private pendingDeletes: Set = new Set(); private enabledSyncTypes: Set = new Set(); + private ignoredFolderRoots: Set = new Set(); private _persistence: IndexeddbPersistence; @@ -245,7 +252,6 @@ export class SharedFolder extends HasProvider { private _settings: NamespacedSettings, private _hsmStore: HSMStore, timeProvider: TimeProvider, - private getIgnoredFolderNameSetting: () => string, relayId?: string, authoritative: boolean = false, remote?: RemoteSharedFolder, @@ -261,6 +267,7 @@ export class SharedFolder extends HasProvider { this.setLoggers(`[SharedFile](${this.path})`); this.fileManager = fileManager; this.vault = vault; + this.refreshIgnoredMarkers(); this.files = new Map(); this.fset = new Files(); this.pendingUpload = new LocalStorage( @@ -1037,7 +1044,7 @@ export class SharedFolder extends HasProvider { return false; } - private addLocalDocs = (types?: SyncType[]) => { + addLocalDocs = (types?: SyncType[]) => { let syncTFiles = this.getSyncFiles(); if (types) { syncTFiles = syncTFiles.filter((tfile) => { @@ -2345,12 +2352,29 @@ export class SharedFolder extends HasProvider { return vPath; } - isIgnoredVirtualPath(vpath: string): boolean { - return isIgnoredVirtualPath(vpath, this.getIgnoredFolderName()); + private getPolicyVirtualPath(path: string): string { + if (path === this.path) return ""; + if (!this.checkPath(path)) return normalizeVirtualPath(path); + return normalizeVirtualPath(path.slice(this.path.length + sep.length)); + } + + refreshIgnoredMarkers(): void { + const roots = new Set(); + const folder = this.vault?.getAbstractFileByPath(this.path); + if (folder instanceof TFolder) { + Vault.recurseChildren(folder, (file: TAbstractFile) => { + if (!(file instanceof TFile) || file.name !== RELAY_IGNORE_FILE_NAME) { + return; + } + const ownerPath = markerOwnerPath(this.getPolicyVirtualPath(file.path)); + roots.add(ownerPath); + }); + } + this.ignoredFolderRoots = roots; } - getIgnoredFolderName(): string { - return this.getIgnoredFolderNameSetting(); + isIgnoredVirtualPath(vpath: string): boolean { + return isIgnoredVirtualPath(vpath, this.ignoredFolderRoots); } isIgnoredVaultPath(path: string): boolean { @@ -2358,6 +2382,49 @@ export class SharedFolder extends HasProvider { return this.isIgnoredVirtualPath(path.slice(this.path.length + sep.length)); } + getIgnoredRootForVaultPath(path: string): string | null { + if (path !== this.path && !this.checkPath(path)) return null; + const root = findIgnoredRootForVirtualPath( + this.getPolicyVirtualPath(path), + this.ignoredFolderRoots, + ); + if (root === null) return null; + return root ? normalizePath(join(this.path, root)) : this.path; + } + + isDirectlyIgnoredVaultFolder(path: string): boolean { + return this.getIgnoredRootForVaultPath(path) === path; + } + + getRelayIgnoreMarkerPath(folderPath: string): string { + return normalizePath(join(folderPath, RELAY_IGNORE_FILE_NAME)); + } + + async addRelayIgnoreMarker(folderPath: string): Promise { + const markerPath = this.getRelayIgnoreMarkerPath(folderPath); + if (!this.vault.getAbstractFileByPath(markerPath)) { + await this.vault.create( + markerPath, + "# Relay ignore\n\nFiles in this folder are not synced by Relay.\n", + ); + } + this.refreshIgnoredMarkers(); + await this.syncFileTree(); + this.fset.update(); + } + + async removeRelayIgnoreMarker(folderPath: string): Promise { + const markerPath = this.getRelayIgnoreMarkerPath(folderPath); + const marker = this.vault.getAbstractFileByPath(markerPath); + if (marker instanceof TFile) { + await this.vault.delete(marker); + } + this.refreshIgnoredMarkers(); + this.addLocalDocs(); + await this.syncFileTree(); + this.fset.update(); + } + getSyncVirtualPath(path: string): string { const vpath = this.getVirtualPath(path); if (this.isIgnoredVirtualPath(vpath)) { @@ -2366,12 +2433,15 @@ export class SharedFolder extends HasProvider { return vpath; } - getIgnoredRemoteEntries(): IgnoredRemoteEntry[] { + getIgnoredRemoteEntries(vaultRootPath?: string): IgnoredRemoteEntry[] { const entries: [string, Meta][] = []; this.syncStore.forEach((meta, path) => { entries.push([path, meta]); }); - return collectIgnoredRemoteEntries(entries, this.getIgnoredFolderName()); + const ignoredRoots = vaultRootPath + ? new Set([this.getPolicyVirtualPath(vaultRootPath)]) + : this.ignoredFolderRoots; + return collectIgnoredRemoteEntries(entries, ignoredRoots); } cleanupIgnoredRemoteEntries(entries = this.getIgnoredRemoteEntries()): number { @@ -3177,6 +3247,7 @@ export class SharedFolder extends HasProvider { const oldInSharedFolder = this.checkPath(oldPath); const newInSharedFolder = this.checkPath(newPath); const oldIgnored = oldInSharedFolder && this.isIgnoredVaultPath(oldPath); + this.refreshIgnoredMarkers(); const newIgnored = newInSharedFolder && this.isIgnoredVaultPath(newPath); const action = classifyRenameSyncAction({ oldInSharedFolder, @@ -3207,15 +3278,11 @@ export class SharedFolder extends HasProvider { const file = this.files.get(guid); if (action === "remove-sync-metadata") { // moving out of shared folder.. destroy the live doc. - this.ydoc.transact(() => { - this.syncStore.delete(oldVPath); - }, this); - if (file) { - file.cleanup(); - file.destroy(); - this.fset.delete(file); - } - this.files.delete(guid); + const deletePaths = this.expandDeletePaths( + [oldVPath], + tfile instanceof TFolder ? [oldVPath] : [], + ); + this.deleteFiles(deletePaths); } else { // moving within shared folder.. move the live doc. const guid = this.syncStore.get(oldVPath); diff --git a/src/components/ManageRemoteFolder.svelte b/src/components/ManageRemoteFolder.svelte index 8dc59e33..f2ce2501 100644 --- a/src/components/ManageRemoteFolder.svelte +++ b/src/components/ManageRemoteFolder.svelte @@ -31,7 +31,6 @@ import { Check, Edit } from "lucide-svelte"; import { UserSelectModal } from "src/ui/UserSelectModal"; import { handleServerError } from "src/utils/toastStore"; - import { IgnoredRemoteEntriesModal } from "src/ui/IgnoredRemoteEntriesModal"; export let plugin: Live; export let remoteFolder: RemoteSharedFolder; export let sharedFolders: SharedFolders; @@ -54,7 +53,6 @@ .items() .find((folder) => folder.guid === remoteFolder.guid); }); - $: ignoredRemoteEntries = $folderStore?.getIgnoredRemoteEntries() || []; // Dynamic role loading for forwards compatibility const availableRoles = derived([plugin.relayManager.roles], ([$roles]) => { @@ -222,12 +220,6 @@ } } - function handleReviewIgnoredRemoteEntries() { - if ($folderStore) { - new IgnoredRemoteEntriesModal(plugin.app, $folderStore).open(); - } - } - async function handleDeleteRemote() { try { await plugin.relayManager.deleteRemote(remoteFolder); @@ -640,17 +632,6 @@ {/if} {#if $folderStore} - {#if ignoredRemoteEntries.length > 0} - - - - {/if} -
- {#if ignoredRemoteEntries.length > 0} - - - - {/if} - - - - - - - {#if !ignoredFolderNameValid} -
- Use one folder name only, without slashes. -
- {/if} -
-
{/if} diff --git a/src/ignoredFolderPolicy.test.ts b/src/ignoredFolderPolicy.test.ts index f8828460..144a36b2 100644 --- a/src/ignoredFolderPolicy.test.ts +++ b/src/ignoredFolderPolicy.test.ts @@ -1,6 +1,7 @@ import { classifyRenameSyncAction, collectIgnoredRemoteEntries, + findIgnoredRootForVirtualPath, isContainedVaultPath, isIgnoredVaultPath, isIgnoredVirtualPath, @@ -14,33 +15,34 @@ import { } from "./SyncTypes"; describe("ignored folder policy", () => { - test("matches exact ignored virtual path segments", () => { - expect(isIgnoredVirtualPath("_private")).toBe(true); - expect(isIgnoredVirtualPath("folder/_private/note.md")).toBe(true); - expect(isIgnoredVirtualPath("/_private/note.md")).toBe(true); - expect(isIgnoredVirtualPath("folder\\_private\\note.md")).toBe(true); - expect(isIgnoredVirtualPath("_private.md")).toBe(false); - expect(isIgnoredVirtualPath("_Private")).toBe(false); - expect(isIgnoredVirtualPath("my_private")).toBe(false); - }); - - test("supports custom ignored folder names", () => { - expect(isIgnoredVirtualPath("folder/_secret/note.md", "_secret")).toBe(true); - expect(isIgnoredVirtualPath("folder/_private/note.md", "_secret")).toBe(false); + test("matches paths under marker roots", () => { + const roots = new Set(["secret", "nested/private"]); + expect(isIgnoredVirtualPath("secret", roots)).toBe(true); + expect(isIgnoredVirtualPath("secret/note.md", roots)).toBe(true); + expect(isIgnoredVirtualPath("/nested/private/note.md", roots)).toBe(true); + expect(isIgnoredVirtualPath("nested\\private\\note.md", roots)).toBe(true); + expect(isIgnoredVirtualPath("secretary/note.md", roots)).toBe(false); + expect(isIgnoredVirtualPath("nested/public/note.md", roots)).toBe(false); }); test("keeps shared folder containment separate from ignored-path checks", () => { - expect(isContainedVaultPath("03-impression/_private/note.md", "03-impression")).toBe( + const roots = new Set(["secret"]); + expect(isContainedVaultPath("03-impression/secret/note.md", "03-impression")).toBe( true, ); - expect(isIgnoredVaultPath("03-impression/_private/note.md", "03-impression")).toBe( + expect(isIgnoredVaultPath("03-impression/secret/note.md", "03-impression", roots)).toBe( true, ); - expect(isIgnoredVaultPath("03-impression/public/note.md", "03-impression")).toBe( + expect(isIgnoredVaultPath("03-impression/public/note.md", "03-impression", roots)).toBe( false, ); }); + test("always ignores the marker file itself", () => { + expect(isIgnoredVirtualPath(".relayignore", new Set())).toBe(true); + expect(isIgnoredVirtualPath("secret/.relayignore", new Set())).toBe(true); + }); + test.each([ [ "remove-sync-metadata", @@ -84,23 +86,21 @@ describe("ignored folder policy", () => { test("collects ignored remote metadata and sorts children before parents", () => { const entries = collectIgnoredRemoteEntries([ - ["_private", makeFolderMeta("folder-guid")], - ["_private/note.md", makeDocumentMeta("doc-guid")], - ["_private/board.canvas", makeCanvasMeta("canvas-guid")], + ["secret", makeFolderMeta("folder-guid")], + ["secret/note.md", makeDocumentMeta("doc-guid")], + ["secret/board.canvas", makeCanvasMeta("canvas-guid")], [ - "_private/assets/image.png", + "secret/assets/image.png", makeFileMeta(SyncType.Image, "image-guid", "image/png", "hash", 1), ], - ["_private.md", makeDocumentMeta("public-guid")], - ["_Private/note.md", makeDocumentMeta("case-guid")], - ["my_private/note.md", makeDocumentMeta("prefix-guid")], - ]); + ["secretary/note.md", makeDocumentMeta("public-guid")], + ], new Set(["secret"])); expect(entries.map((entry) => entry.path)).toEqual([ - "_private/assets/image.png", - "_private/board.canvas", - "_private/note.md", - "_private", + "secret/assets/image.png", + "secret/board.canvas", + "secret/note.md", + "secret", ]); expect(entries.map((entry) => entry.guid)).toEqual([ "image-guid", @@ -109,4 +109,13 @@ describe("ignored folder policy", () => { "folder-guid", ]); }); + + test("finds the deepest marker root for a path", () => { + const roots = new Set(["secret", "secret/deeper"]); + expect(findIgnoredRootForVirtualPath("secret/deeper/note.md", roots)).toBe( + "secret/deeper", + ); + expect(findIgnoredRootForVirtualPath("secret/other.md", roots)).toBe("secret"); + expect(findIgnoredRootForVirtualPath("public/other.md", roots)).toBe(null); + }); }); diff --git a/src/ignoredFolderPolicy.ts b/src/ignoredFolderPolicy.ts index c61105ff..877e1c81 100644 --- a/src/ignoredFolderPolicy.ts +++ b/src/ignoredFolderPolicy.ts @@ -1,8 +1,8 @@ import { sep } from "path-browserify"; import type { Meta } from "./SyncTypes"; import { - DEFAULT_IGNORED_FOLDER_NAME, - pathContainsIgnoredFolderSegment, + isRelayIgnoreMarkerPath, + normalizeVirtualPath, } from "./privateFolderIgnore"; export type RenameSyncAction = "ignore" | "remove-sync-metadata" | "upload" | "move-sync-metadata"; @@ -15,20 +15,21 @@ export interface IgnoredRemoteEntry { export function isIgnoredVirtualPath( vpath: string, - ignoredFolderName = DEFAULT_IGNORED_FOLDER_NAME, + ignoredRoots: Iterable, ): boolean { - return pathContainsIgnoredFolderSegment(vpath, ignoredFolderName); + if (isRelayIgnoreMarkerPath(vpath)) return true; + return findIgnoredRootForVirtualPath(vpath, ignoredRoots) !== null; } export function isIgnoredVaultPath( vaultPath: string, sharedFolderPath: string, - ignoredFolderName = DEFAULT_IGNORED_FOLDER_NAME, + ignoredRoots: Iterable, ): boolean { if (!isContainedVaultPath(vaultPath, sharedFolderPath)) return false; return isIgnoredVirtualPath( vaultPath.slice(sharedFolderPath.length + sep.length), - ignoredFolderName, + ignoredRoots, ); } @@ -56,10 +57,10 @@ export function classifyRenameSyncAction(input: { export function collectIgnoredRemoteEntries( entries: Iterable<[string, Meta]>, - ignoredFolderName = DEFAULT_IGNORED_FOLDER_NAME, + ignoredRoots: Iterable, ): IgnoredRemoteEntry[] { return Array.from(entries) - .filter(([path]) => isIgnoredVirtualPath(path, ignoredFolderName)) + .filter(([path]) => isIgnoredVirtualPath(path, ignoredRoots)) .map(([path, meta]) => ({ path, guid: meta.id, @@ -77,3 +78,23 @@ export function compareDeepestPathFirst( if (aDepth !== bDepth) return bDepth - aDepth; return a.path.localeCompare(b.path); } + +export function findIgnoredRootForVirtualPath( + vpath: string, + ignoredRoots: Iterable, +): string | null { + const normalizedPath = normalizeVirtualPath(vpath); + const roots = Array.from(ignoredRoots) + .map((root) => normalizeVirtualPath(root)) + .sort((a, b) => b.length - a.length); + + for (const root of roots) { + if (!root) { + return root; + } + if (normalizedPath === root || normalizedPath.startsWith(root + "/")) { + return root; + } + } + return null; +} diff --git a/src/main.ts b/src/main.ts index 5c8cb96c..3b18e0a1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -91,10 +91,8 @@ import { setPluginRequestConfig, } from "./customFetch"; import { RelayDebugAPI } from "./RelayDebugAPI"; -import { - DEFAULT_IGNORED_FOLDER_NAME, - normalizeIgnoredFolderName, -} from "./privateFolderIgnore"; +import { isRelayIgnoreMarkerPath } from "./privateFolderIgnore"; +import { IgnoredRemoteEntriesModal } from "./ui/IgnoredRemoteEntriesModal"; interface DebugSettings { debugging: boolean; @@ -106,7 +104,6 @@ const DEFAULT_DEBUG_SETTINGS: DebugSettings = { interface RelaySettings extends FeatureFlags, DebugSettings { sharedFolders: SharedFolderSettings[]; - ignoredFolderName: string; release: ReleaseSettings; endpoints: EndpointSettings; } @@ -116,7 +113,6 @@ const DEFAULT_SETTINGS: RelaySettings = { channel: "stable", }, sharedFolders: [], - ignoredFolderName: DEFAULT_IGNORED_FOLDER_NAME, endpoints: {}, ...FeatureFlagDefaults, ...DEFAULT_DEBUG_SETTINGS, @@ -179,24 +175,6 @@ export default class Live extends Plugin { private _hsmStore!: HSMStore; promises = new PromiseTracker(); - getIgnoredFolderName(): string { - return normalizeIgnoredFolderName(this.settings?.get()?.ignoredFolderName); - } - - async setIgnoredFolderName(name: string): Promise { - const ignoredFolderName = normalizeIgnoredFolderName(name); - await this.settings.update((settings) => ({ - ...settings, - ignoredFolderName, - })); - this.sharedFolders?.forEach((folder) => { - void folder.syncFileTree().catch((error) => { - this.warn("Failed to resync after ignored folder setting changed", error); - }); - }); - this.folderNavDecorations?.refresh(); - } - enableDebugging(save?: boolean) { setDebugging(true); console.warn("RelayInstances", RelayInstances); @@ -894,13 +872,14 @@ export default class Live extends Plugin { this.registerEvent( this.app.workspace.on("file-menu", (menu, file) => { if (file instanceof TFolder) { - const folder = this.sharedFolders.find( + const exactSharedFolder = this.sharedFolders.find( (sharedFolder) => sharedFolder.path === file.path, ); + const folder = exactSharedFolder ?? this.sharedFolders.lookup(file.path); if (!folder) { return; } - if (folder.relayId) { + if (exactSharedFolder && folder.relayId) { menu.addItem((item) => { item .setTitle("Relay: Relay settings") @@ -934,7 +913,7 @@ export default class Live extends Plugin { this._liveViews.refresh("folder connection toggle"); }); }); - } else { + } else if (exactSharedFolder) { menu.addItem((item) => { item .setTitle("Relay: Local folder settings") @@ -944,7 +923,7 @@ export default class Live extends Plugin { }); }); } - if (folder.relayId && folder.connected && !folder.localOnly) { + if (exactSharedFolder && folder.relayId && folder.connected && !folder.localOnly) { menu.addItem((item) => { item .setTitle("Relay: Sync") @@ -961,6 +940,34 @@ export default class Live extends Plugin { }); }); } + if (!exactSharedFolder) { + const ignoredRoot = folder.getIgnoredRootForVaultPath(file.path); + if (ignoredRoot === file.path) { + menu.addItem((item) => { + item + .setTitle("Relay: Sync this folder") + .setIcon("folder-sync") + .onClick(() => { + void this.removeRelayIgnoreMarker(folder, file); + }); + }); + } else if (ignoredRoot) { + menu.addItem((item) => { + item + .setTitle("Relay: Not synced by parent .relayignore") + .setIcon("ban"); + }); + } else { + menu.addItem((item) => { + item + .setTitle("Relay: Do not sync this folder") + .setIcon("folder-x") + .onClick(() => { + void this.addRelayIgnoreMarker(folder, file); + }); + }); + } + } } else if (file instanceof TFile) { const folder = this.sharedFolders.lookup(file.path); const ifile = @@ -1013,6 +1020,35 @@ export default class Live extends Plugin { }); } + private async addRelayIgnoreMarker(folder: SharedFolder, target: TFolder): Promise { + try { + await folder.addRelayIgnoreMarker(target.path); + this.folderNavDecorations.refresh(); + this._liveViews.refresh("relayignore added"); + const entries = folder.getIgnoredRemoteEntries(target.path); + if (entries.length > 0) { + new IgnoredRemoteEntriesModal(this.app, folder, entries).open(); + } else { + new Notice(`Relay will no longer sync ${target.path}.`); + } + } catch (error) { + this.warn("Failed to add .relayignore", error); + new Notice(`Failed to add .relayignore to ${target.path}`); + } + } + + private async removeRelayIgnoreMarker(folder: SharedFolder, target: TFolder): Promise { + try { + await folder.removeRelayIgnoreMarker(target.path); + this.folderNavDecorations.refresh(); + this._liveViews.refresh("relayignore removed"); + new Notice(`Relay will sync ${target.path}.`); + } catch (error) { + this.warn("Failed to remove .relayignore", error); + new Notice(`Failed to remove .relayignore from ${target.path}`); + } + } + private _createSharedFolder( path: string, guid: string, @@ -1064,7 +1100,6 @@ export default class Live extends Plugin { folderSettings, this._hsmStore, this.timeProvider, - () => this.getIgnoredFolderName(), relayId, authoritative, remote, @@ -1238,6 +1273,14 @@ export default class Live extends Plugin { // NOTE: this is called on every file at startup... const folder = this.sharedFolders.lookup(tfile.path); if (folder) { + if (tfile instanceof TFile && isRelayIgnoreMarkerPath(tfile.path)) { + folder.refreshIgnoredMarkers(); + void folder.syncFileTree().catch((error) => { + this.warn("Failed to resync after .relayignore was created", error); + }); + this.folderNavDecorations.refresh(); + return; + } if (folder.isIgnoredVaultPath(tfile.path)) return; const newDocs = folder.placeHold([tfile]); if (newDocs.length > 0) { @@ -1253,12 +1296,43 @@ export default class Live extends Plugin { this.registerEvent( this.app.vault.on("delete", (file) => { + if (file instanceof TFile && isRelayIgnoreMarkerPath(file.path)) { + const folder = this.sharedFolders.lookup(file.path); + if (folder) { + folder.refreshIgnoredMarkers(); + folder.addLocalDocs(); + void folder.syncFileTree().catch((error) => { + this.warn("Failed to resync after .relayignore was deleted", error); + }); + this.folderNavDecorations.refresh(); + } + return; + } this.queueVaultDelete(file, vaultLog); }), ); this.registerEvent( this.app.vault.on("rename", (file, oldPath) => { + if ( + (file instanceof TFile && isRelayIgnoreMarkerPath(file.path)) || + isRelayIgnoreMarkerPath(oldPath) + ) { + const affectedFolders = new Set(); + const fromFolder = this.sharedFolders.lookup(oldPath); + const toFolder = this.sharedFolders.lookup(file.path); + if (fromFolder) affectedFolders.add(fromFolder); + if (toFolder) affectedFolders.add(toFolder); + affectedFolders.forEach((folder) => { + folder.refreshIgnoredMarkers(); + folder.addLocalDocs(); + void folder.syncFileTree().catch((error) => { + this.warn("Failed to resync after .relayignore was renamed", error); + }); + }); + this.folderNavDecorations.refresh(); + return; + } // TODO this doesn't work for empty folders. if (file instanceof TFolder) { const sharedFolder = this.sharedFolders.find((folder) => { diff --git a/src/privateFolderIgnore.test.ts b/src/privateFolderIgnore.test.ts index 556e99b9..88333936 100644 --- a/src/privateFolderIgnore.test.ts +++ b/src/privateFolderIgnore.test.ts @@ -1,49 +1,34 @@ import { - DEFAULT_IGNORED_FOLDER_NAME, - isValidIgnoredFolderName, - normalizeIgnoredFolderName, - pathContainsIgnoredFolderSegment, + isRelayIgnoreMarkerPath, + markerOwnerPath, + normalizeVirtualPath, + relayIgnoreMarkerPath, } from "./privateFolderIgnore"; -describe("pathContainsIgnoredFolderSegment", () => { +describe(".relayignore path helpers", () => { test.each([ - "_private", - "folder/_private", - "folder/_private/note.md", - "/_private/note.md", - "folder\\_private\\note.md", - ])("matches ignored folder segment in %s", (path) => { - expect(pathContainsIgnoredFolderSegment(path)).toBe(true); + ".relayignore", + "folder/.relayignore", + "/folder/.relayignore", + "folder\\.relayignore", + ])("detects marker path %s", (path) => { + expect(isRelayIgnoreMarkerPath(path)).toBe(true); }); - test.each(["_private.md", "not_private", "_Private", "folder/my_private/note.md"])( - "does not match non-private folder segment in %s", + test.each(["relayignore", ".relayignore.md", "folder/.relayignore/note.md"])( + "does not treat non-marker path %s as marker", (path) => { - expect(pathContainsIgnoredFolderSegment(path)).toBe(false); + expect(isRelayIgnoreMarkerPath(path)).toBe(false); }, ); - test("uses custom ignored folder name", () => { - expect(pathContainsIgnoredFolderSegment("folder/_secret/note.md", "_secret")).toBe( - true, - ); - expect(pathContainsIgnoredFolderSegment("folder/_private/note.md", "_secret")).toBe( - false, - ); + test("normalizes virtual paths", () => { + expect(normalizeVirtualPath("/folder\\child//note.md")).toBe("folder/child/note.md"); }); - test.each(["", " ", "nested/folder", "nested\\folder"])( - "normalizes invalid setting %s to default", - (name) => { - expect(normalizeIgnoredFolderName(name)).toBe(DEFAULT_IGNORED_FOLDER_NAME); - }, - ); - - test("validates user-provided ignored folder names", () => { - expect(isValidIgnoredFolderName("_private")).toBe(true); - expect(isValidIgnoredFolderName(" _secret ")).toBe(true); - expect(isValidIgnoredFolderName("nested/folder")).toBe(false); - expect(isValidIgnoredFolderName("nested\\folder")).toBe(false); - expect(isValidIgnoredFolderName(" ")).toBe(false); + test("builds marker paths and owners", () => { + expect(relayIgnoreMarkerPath("folder/child")).toBe("folder/child/.relayignore"); + expect(markerOwnerPath("folder/child/.relayignore")).toBe("folder/child"); + expect(markerOwnerPath(".relayignore")).toBe(""); }); }); diff --git a/src/privateFolderIgnore.ts b/src/privateFolderIgnore.ts index c80b78a8..a7bfd8a9 100644 --- a/src/privateFolderIgnore.ts +++ b/src/privateFolderIgnore.ts @@ -1,27 +1,29 @@ "use strict"; -export const DEFAULT_IGNORED_FOLDER_NAME = "_private"; +export const RELAY_IGNORE_FILE_NAME = ".relayignore"; -export function isValidIgnoredFolderName(name: string): boolean { - const trimmed = name.trim(); - return !!trimmed && !/[\\/]/.test(trimmed); +export function splitVaultPath(path: string): string[] { + return path.split(/[\\/]+/).filter(Boolean); } -export function normalizeIgnoredFolderName(name?: string): string { - const trimmed = name?.trim(); - if (!trimmed || !isValidIgnoredFolderName(trimmed)) { - return DEFAULT_IGNORED_FOLDER_NAME; - } - return trimmed; +export function normalizeVirtualPath(path: string): string { + return splitVaultPath(path).join("/"); +} + +export function isRelayIgnoreMarkerPath(path: string): boolean { + const parts = splitVaultPath(path); + return parts[parts.length - 1] === RELAY_IGNORE_FILE_NAME; } -export function pathContainsIgnoredFolderSegment( - path: string, - ignoredFolderName = DEFAULT_IGNORED_FOLDER_NAME, -): boolean { - const ignoredSegment = normalizeIgnoredFolderName(ignoredFolderName); - return path - .split(/[\\/]+/) - .filter(Boolean) - .includes(ignoredSegment); +export function relayIgnoreMarkerPath(folderPath: string): string { + const normalized = normalizeVirtualPath(folderPath); + return normalized ? `${normalized}/${RELAY_IGNORE_FILE_NAME}` : RELAY_IGNORE_FILE_NAME; +} + +export function markerOwnerPath(markerPath: string): string { + const parts = splitVaultPath(markerPath); + if (parts[parts.length - 1] === RELAY_IGNORE_FILE_NAME) { + parts.pop(); + } + return parts.join("/"); } diff --git a/src/ui/IgnoredRemoteEntriesModal.ts b/src/ui/IgnoredRemoteEntriesModal.ts index 14210f20..943248a4 100644 --- a/src/ui/IgnoredRemoteEntriesModal.ts +++ b/src/ui/IgnoredRemoteEntriesModal.ts @@ -8,10 +8,11 @@ export class IgnoredRemoteEntriesModal extends Modal { constructor( app: App, private sharedFolder: SharedFolder, + entries?: IgnoredRemoteEntry[], ) { super(app); - this.entries = sharedFolder.getIgnoredRemoteEntries(); - this.setTitle("Ignored remote entries"); + this.entries = entries ?? sharedFolder.getIgnoredRemoteEntries(); + this.setTitle("Remove ignored Relay entries"); } onOpen() { @@ -19,7 +20,7 @@ export class IgnoredRemoteEntriesModal extends Modal { contentEl.empty(); contentEl.createEl("p", { - text: `${this.entries.length} remote entr${this.entries.length === 1 ? "y" : "ies"} match the ignored folder name "${this.sharedFolder.getIgnoredFolderName()}". Cleanup removes Relay metadata only. Local files are not deleted, trashed, moved, or rewritten.`, + text: `${this.entries.length} remote Relay entr${this.entries.length === 1 ? "y" : "ies"} are already synced under this .relayignore folder. Removing them deletes the subtree from the Relay server, preserves local files on this device, and may appear as remote deletions on other devices.`, }); const list = contentEl.createEl("ul"); From 0364ad187e4687b0da9f8df6246fb74092b96631 Mon Sep 17 00:00:00 2001 From: Matt Derman Date: Sun, 31 May 2026 17:12:52 +0200 Subject: [PATCH 3/4] Show ignored folder state in file explorer --- src/ui/FolderNav.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++ styles.css | 23 +++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/ui/FolderNav.ts b/src/ui/FolderNav.ts index a9f01d1e..4494af06 100644 --- a/src/ui/FolderNav.ts +++ b/src/ui/FolderNav.ts @@ -5,6 +5,7 @@ import { Vault, Workspace, WorkspaceLeaf, + setIcon, } from "obsidian"; import { SharedFolder, SharedFolders } from "../SharedFolder"; import type { ConnectionState } from "src/HasProvider"; @@ -258,6 +259,64 @@ class FolderPillVisitor extends BaseVisitor { } } +class IgnoredFolderDecoration implements Destroyable { + private iconEl: HTMLElement; + + constructor( + private el: HTMLElement, + private label: string, + private direct: boolean, + ) { + this.iconEl = document.createElement("span"); + this.iconEl.addClass("system3-ignored-folder-icon"); + this.iconEl.setAttribute("aria-label", label); + this.iconEl.setAttribute("title", label); + setIcon(this.iconEl, "cloud-off"); + this.el.appendChild(this.iconEl); + this.update(label, direct); + } + + update(label: string, direct: boolean) { + this.label = label; + this.direct = direct; + this.iconEl.setAttribute("aria-label", label); + this.iconEl.setAttribute("title", label); + this.iconEl.toggleClass("system3-ignored-folder-icon-inherited", !direct); + } + + destroy() { + this.iconEl.remove(); + } +} + +class IgnoredFolderVisitor extends BaseVisitor { + visitFolder( + folder: TFolder, + item: FolderItem, + storage?: IgnoredFolderDecoration, + sharedFolder?: SharedFolder, + ): IgnoredFolderDecoration | null { + if (sharedFolder && sharedFolder.checkPath(folder.path)) { + const ignoredRoot = sharedFolder.getIgnoredRootForVaultPath(folder.path); + if (ignoredRoot) { + const direct = ignoredRoot === folder.path; + const label = direct + ? "Relay is not syncing this folder (.relayignore)" + : "Relay is not syncing this folder because a parent has .relayignore"; + if (storage) { + storage.update(label, direct); + return storage; + } + return new IgnoredFolderDecoration(item.selfEl, label, direct); + } + } + if (storage) { + storage.destroy(); + } + return null; + } +} + class QueueWatcher implements Destroyable { private unsubscribers: Unsubscriber[] = []; private titleEl: HTMLElement; @@ -880,6 +939,7 @@ export class FolderNavigationDecorations { const visitors = []; visitors.push(new FolderBarVisitor()); visitors.push(new FolderPillVisitor()); + visitors.push(new IgnoredFolderVisitor()); withFlag(flag.enableDocumentStatus, () => { visitors.push(new FileStatusVisitor()); visitors.push( diff --git a/styles.css b/styles.css index 73807dbc..909ab64d 100644 --- a/styles.css +++ b/styles.css @@ -62,6 +62,29 @@ .nav-folder-title-content { margin-inline-end: auto; } + +.system3-ignored-folder-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1em; + height: 1em; + margin-inline-start: 0.35em; + color: var(--text-warning, var(--color-orange)); + flex: 0 0 auto; + opacity: 0.95; +} + +.system3-ignored-folder-icon svg { + width: 0.9em; + height: 0.9em; + stroke-width: 2.25; +} + +.system3-ignored-folder-icon-inherited { + opacity: 0.45; +} + .system3-sync-status-folder { position: relative; cursor: pointer; From 46193369fc49806ae17f86d5ae9838caa3524b45 Mon Sep 17 00:00:00 2001 From: Matt Derman Date: Sun, 31 May 2026 17:28:14 +0200 Subject: [PATCH 4/4] Detect relayignore markers through adapter --- src/SharedFolder.ts | 44 ++++++++++++++++++++++++-------------------- src/main.ts | 28 +++++++++++++++------------- 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/SharedFolder.ts b/src/SharedFolder.ts index 5b444f1b..e80b1b5c 100644 --- a/src/SharedFolder.ts +++ b/src/SharedFolder.ts @@ -267,7 +267,7 @@ export class SharedFolder extends HasProvider { this.setLoggers(`[SharedFile](${this.path})`); this.fileManager = fileManager; this.vault = vault; - this.refreshIgnoredMarkers(); + void this.refreshIgnoredMarkers().then(() => this.fset.update()); this.files = new Map(); this.fset = new Files(); this.pendingUpload = new LocalStorage( @@ -2358,17 +2358,22 @@ export class SharedFolder extends HasProvider { return normalizeVirtualPath(path.slice(this.path.length + sep.length)); } - refreshIgnoredMarkers(): void { + async refreshIgnoredMarkers(): Promise { const roots = new Set(); - const folder = this.vault?.getAbstractFileByPath(this.path); - if (folder instanceof TFolder) { - Vault.recurseChildren(folder, (file: TAbstractFile) => { - if (!(file instanceof TFile) || file.name !== RELAY_IGNORE_FILE_NAME) { - return; - } - const ownerPath = markerOwnerPath(this.getPolicyVirtualPath(file.path)); - roots.add(ownerPath); - }); + const scan = async (folderPath: string): Promise => { + const markerPath = this.getRelayIgnoreMarkerPath(folderPath); + if (await this.vault.adapter.exists(markerPath)) { + roots.add(markerOwnerPath(this.getPolicyVirtualPath(markerPath))); + } + const listed = await this.vault.adapter.list(folderPath); + await Promise.all( + listed.folders.map((childPath) => scan(normalizePath(childPath))), + ); + }; + try { + await scan(this.path); + } catch (error) { + this.warn("Failed to refresh .relayignore markers", error); } this.ignoredFolderRoots = roots; } @@ -2402,24 +2407,23 @@ export class SharedFolder extends HasProvider { async addRelayIgnoreMarker(folderPath: string): Promise { const markerPath = this.getRelayIgnoreMarkerPath(folderPath); - if (!this.vault.getAbstractFileByPath(markerPath)) { - await this.vault.create( + if (!(await this.vault.adapter.exists(markerPath))) { + await this.vault.adapter.write( markerPath, "# Relay ignore\n\nFiles in this folder are not synced by Relay.\n", ); } - this.refreshIgnoredMarkers(); + await this.refreshIgnoredMarkers(); await this.syncFileTree(); this.fset.update(); } async removeRelayIgnoreMarker(folderPath: string): Promise { const markerPath = this.getRelayIgnoreMarkerPath(folderPath); - const marker = this.vault.getAbstractFileByPath(markerPath); - if (marker instanceof TFile) { - await this.vault.delete(marker); + if (await this.vault.adapter.exists(markerPath)) { + await this.vault.adapter.remove(markerPath); } - this.refreshIgnoredMarkers(); + await this.refreshIgnoredMarkers(); this.addLocalDocs(); await this.syncFileTree(); this.fset.update(); @@ -3242,12 +3246,12 @@ export class SharedFolder extends HasProvider { } } - renameFile(tfile: TAbstractFile, oldPath: string) { + async renameFile(tfile: TAbstractFile, oldPath: string): Promise { const newPath = tfile.path; const oldInSharedFolder = this.checkPath(oldPath); const newInSharedFolder = this.checkPath(newPath); const oldIgnored = oldInSharedFolder && this.isIgnoredVaultPath(oldPath); - this.refreshIgnoredMarkers(); + await this.refreshIgnoredMarkers(); const newIgnored = newInSharedFolder && this.isIgnoredVaultPath(newPath); const action = classifyRenameSyncAction({ oldInSharedFolder, diff --git a/src/main.ts b/src/main.ts index 3b18e0a1..72a3fd08 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1274,8 +1274,9 @@ export default class Live extends Plugin { const folder = this.sharedFolders.lookup(tfile.path); if (folder) { if (tfile instanceof TFile && isRelayIgnoreMarkerPath(tfile.path)) { - folder.refreshIgnoredMarkers(); - void folder.syncFileTree().catch((error) => { + void folder.refreshIgnoredMarkers().then(() => { + return folder.syncFileTree(); + }).catch((error) => { this.warn("Failed to resync after .relayignore was created", error); }); this.folderNavDecorations.refresh(); @@ -1299,9 +1300,10 @@ export default class Live extends Plugin { if (file instanceof TFile && isRelayIgnoreMarkerPath(file.path)) { const folder = this.sharedFolders.lookup(file.path); if (folder) { - folder.refreshIgnoredMarkers(); - folder.addLocalDocs(); - void folder.syncFileTree().catch((error) => { + void folder.refreshIgnoredMarkers().then(() => { + folder.addLocalDocs(); + return folder.syncFileTree(); + }).catch((error) => { this.warn("Failed to resync after .relayignore was deleted", error); }); this.folderNavDecorations.refresh(); @@ -1313,7 +1315,7 @@ export default class Live extends Plugin { ); this.registerEvent( - this.app.vault.on("rename", (file, oldPath) => { + this.app.vault.on("rename", async (file, oldPath) => { if ( (file instanceof TFile && isRelayIgnoreMarkerPath(file.path)) || isRelayIgnoreMarkerPath(oldPath) @@ -1323,13 +1325,13 @@ export default class Live extends Plugin { const toFolder = this.sharedFolders.lookup(file.path); if (fromFolder) affectedFolders.add(fromFolder); if (toFolder) affectedFolders.add(toFolder); - affectedFolders.forEach((folder) => { - folder.refreshIgnoredMarkers(); + await Promise.all(Array.from(affectedFolders).map(async (folder) => { + await folder.refreshIgnoredMarkers(); folder.addLocalDocs(); - void folder.syncFileTree().catch((error) => { + await folder.syncFileTree().catch((error) => { this.warn("Failed to resync after .relayignore was renamed", error); }); - }); + })); this.folderNavDecorations.refresh(); return; } @@ -1350,13 +1352,13 @@ export default class Live extends Plugin { if (fromFolder && toFolder) { // between two shared folders vaultLog("Rename", file.path, oldPath); - fromFolder.renameFile(file, oldPath); - toFolder.renameFile(file, oldPath); + await fromFolder.renameFile(file, oldPath); + await toFolder.renameFile(file, oldPath); this._liveViews.refresh("rename"); this.folderNavDecorations.quickRefresh(); } else if (folder) { vaultLog("Rename", file.path, oldPath); - folder.renameFile(file, oldPath); + await folder.renameFile(file, oldPath); this._liveViews.refresh("rename"); this.folderNavDecorations.refresh(); }