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..e80b1b5c 100644 --- a/src/SharedFolder.ts +++ b/src/SharedFolder.ts @@ -33,6 +33,19 @@ import { BackgroundSync } from "./BackgroundSync"; import type { NamespacedSettings } from "./SettingsStorage"; import { RelayInstances, metrics } from "./debug"; 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"; @@ -68,6 +81,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 +153,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 @@ -183,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; @@ -194,6 +215,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(); @@ -241,6 +267,7 @@ export class SharedFolder extends HasProvider { this.setLoggers(`[SharedFile](${this.path})`); this.fileManager = fileManager; this.vault = vault; + void this.refreshIgnoredMarkers().then(() => this.fset.update()); this.files = new Map(); this.fset = new Files(); this.pendingUpload = new LocalStorage( @@ -685,16 +712,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 +739,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); @@ -1007,12 +1044,12 @@ export class SharedFolder extends HasProvider { return false; } - private addLocalDocs = (types?: SyncType[]) => { + addLocalDocs = (types?: SyncType[]) => { let syncTFiles = this.getSyncFiles(); 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 +1059,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 +1119,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 +1144,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 +1535,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 +1832,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 +1847,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 +1889,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 +1971,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 +1987,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 +2089,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 +2118,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 +2131,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 +2342,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 +2352,113 @@ export class SharedFolder extends HasProvider { return vPath; } + 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)); + } + + async refreshIgnoredMarkers(): Promise { + const roots = new Set(); + 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; + } + + isIgnoredVirtualPath(vpath: string): boolean { + return isIgnoredVirtualPath(vpath, this.ignoredFolderRoots); + } + + isIgnoredVaultPath(path: string): boolean { + if (!this.checkPath(path)) return false; + 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 (!(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", + ); + } + await this.refreshIgnoredMarkers(); + await this.syncFileTree(); + this.fset.update(); + } + + async removeRelayIgnoreMarker(folderPath: string): Promise { + const markerPath = this.getRelayIgnoreMarkerPath(folderPath); + if (await this.vault.adapter.exists(markerPath)) { + await this.vault.adapter.remove(markerPath); + } + await this.refreshIgnoredMarkers(); + this.addLocalDocs(); + await this.syncFileTree(); + this.fset.update(); + } + + 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(vaultRootPath?: string): IgnoredRemoteEntry[] { + const entries: [string, Meta][] = []; + this.syncStore.forEach((meta, path) => { + entries.push([path, meta]); + }); + const ignoredRoots = vaultRootPath + ? new Set([this.getPolicyVirtualPath(vaultRootPath)]) + : this.ignoredFolderRoots; + return collectIgnoredRemoteEntries(entries, ignoredRoots); + } + + 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 +2622,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 +2675,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 +3133,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) { @@ -2816,25 +3246,30 @@ export class SharedFolder extends HasProvider { } } - renameFile(tfile: TAbstractFile, oldPath: string) { + async renameFile(tfile: TAbstractFile, oldPath: string): Promise { 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); + await this.refreshIgnoredMarkers(); + 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,17 +3280,13 @@ 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); - }, 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/ManageSharedFolder.svelte b/src/components/ManageSharedFolder.svelte index 0faee329..011138aa 100644 --- a/src/components/ManageSharedFolder.svelte +++ b/src/components/ManageSharedFolder.svelte @@ -5,7 +5,7 @@ import type Live from "src/main"; import { type SharedFolder } from "src/SharedFolder"; import { debounce } from "obsidian"; - import { createEventDispatcher, onDestroy, onMount } from "svelte"; + import { createEventDispatcher } from "svelte"; import Breadcrumbs from "./Breadcrumbs.svelte"; export let plugin: Live; @@ -29,6 +29,7 @@ } dispatch("goBack", {}); } + { + 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", () => { + const roots = new Set(["secret"]); + expect(isContainedVaultPath("03-impression/secret/note.md", "03-impression")).toBe( + true, + ); + expect(isIgnoredVaultPath("03-impression/secret/note.md", "03-impression", roots)).toBe( + true, + ); + 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", + { + 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([ + ["secret", makeFolderMeta("folder-guid")], + ["secret/note.md", makeDocumentMeta("doc-guid")], + ["secret/board.canvas", makeCanvasMeta("canvas-guid")], + [ + "secret/assets/image.png", + makeFileMeta(SyncType.Image, "image-guid", "image/png", "hash", 1), + ], + ["secretary/note.md", makeDocumentMeta("public-guid")], + ], new Set(["secret"])); + + expect(entries.map((entry) => entry.path)).toEqual([ + "secret/assets/image.png", + "secret/board.canvas", + "secret/note.md", + "secret", + ]); + expect(entries.map((entry) => entry.guid)).toEqual([ + "image-guid", + "canvas-guid", + "doc-guid", + "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 new file mode 100644 index 00000000..877e1c81 --- /dev/null +++ b/src/ignoredFolderPolicy.ts @@ -0,0 +1,100 @@ +import { sep } from "path-browserify"; +import type { Meta } from "./SyncTypes"; +import { + isRelayIgnoreMarkerPath, + normalizeVirtualPath, +} 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, + ignoredRoots: Iterable, +): boolean { + if (isRelayIgnoreMarkerPath(vpath)) return true; + return findIgnoredRootForVirtualPath(vpath, ignoredRoots) !== null; +} + +export function isIgnoredVaultPath( + vaultPath: string, + sharedFolderPath: string, + ignoredRoots: Iterable, +): boolean { + if (!isContainedVaultPath(vaultPath, sharedFolderPath)) return false; + return isIgnoredVirtualPath( + vaultPath.slice(sharedFolderPath.length + sep.length), + ignoredRoots, + ); +} + +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]>, + ignoredRoots: Iterable, +): IgnoredRemoteEntry[] { + return Array.from(entries) + .filter(([path]) => isIgnoredVirtualPath(path, ignoredRoots)) + .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); +} + +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 1d8789a2..72a3fd08 100644 --- a/src/main.ts +++ b/src/main.ts @@ -91,6 +91,8 @@ import { setPluginRequestConfig, } from "./customFetch"; import { RelayDebugAPI } from "./RelayDebugAPI"; +import { isRelayIgnoreMarkerPath } from "./privateFolderIgnore"; +import { IgnoredRemoteEntriesModal } from "./ui/IgnoredRemoteEntriesModal"; interface DebugSettings { debugging: boolean; @@ -266,6 +268,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; @@ -867,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") @@ -907,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") @@ -917,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") @@ -934,9 +940,40 @@ 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 = folder?.getFile(file); + const ifile = + folder && !folder.isIgnoredVaultPath(file.path) + ? folder.getFile(file) + : null; if (ifile && isSyncFile(ifile)) { menu.addItem((item) => { item @@ -983,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, @@ -1207,6 +1273,16 @@ 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)) { + void folder.refreshIgnoredMarkers().then(() => { + return 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) { folder.uploadFile(tfile); @@ -1221,12 +1297,44 @@ 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) { + void folder.refreshIgnoredMarkers().then(() => { + folder.addLocalDocs(); + return 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) => { + this.app.vault.on("rename", async (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); + await Promise.all(Array.from(affectedFolders).map(async (folder) => { + await folder.refreshIgnoredMarkers(); + folder.addLocalDocs(); + await 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) => { @@ -1244,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(); } @@ -1261,6 +1369,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..88333936 --- /dev/null +++ b/src/privateFolderIgnore.test.ts @@ -0,0 +1,34 @@ +import { + isRelayIgnoreMarkerPath, + markerOwnerPath, + normalizeVirtualPath, + relayIgnoreMarkerPath, +} from "./privateFolderIgnore"; + +describe(".relayignore path helpers", () => { + test.each([ + ".relayignore", + "folder/.relayignore", + "/folder/.relayignore", + "folder\\.relayignore", + ])("detects marker path %s", (path) => { + expect(isRelayIgnoreMarkerPath(path)).toBe(true); + }); + + test.each(["relayignore", ".relayignore.md", "folder/.relayignore/note.md"])( + "does not treat non-marker path %s as marker", + (path) => { + expect(isRelayIgnoreMarkerPath(path)).toBe(false); + }, + ); + + test("normalizes virtual paths", () => { + expect(normalizeVirtualPath("/folder\\child//note.md")).toBe("folder/child/note.md"); + }); + + 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 new file mode 100644 index 00000000..a7bfd8a9 --- /dev/null +++ b/src/privateFolderIgnore.ts @@ -0,0 +1,29 @@ +"use strict"; + +export const RELAY_IGNORE_FILE_NAME = ".relayignore"; + +export function splitVaultPath(path: string): string[] { + return path.split(/[\\/]+/).filter(Boolean); +} + +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 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/FolderNav.ts b/src/ui/FolderNav.ts index 5859f882..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; @@ -321,6 +380,7 @@ class QueueWatcherVisitor extends BaseVisitor { sharedFolder && sharedFolder.ready && sharedFolder.checkPath(file.path) && + !sharedFolder.isIgnoredVaultPath(file.path) && Document.checkExtension(file.path) ) { return ( @@ -470,6 +530,7 @@ class NotSyncedPillVisitor extends BaseVisitor { if ( sharedFolder && sharedFolder.checkPath(file.path) && + !sharedFolder.isIgnoredVaultPath(file.path) && (sharedFolder.isStorageBlockedTFile(file) || !sharedFolder.isSyncableTFile(file)) ) { @@ -538,7 +599,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 +666,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(); @@ -878,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/src/ui/IgnoredRemoteEntriesModal.ts b/src/ui/IgnoredRemoteEntriesModal.ts new file mode 100644 index 00000000..943248a4 --- /dev/null +++ b/src/ui/IgnoredRemoteEntriesModal.ts @@ -0,0 +1,51 @@ +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, + entries?: IgnoredRemoteEntry[], + ) { + super(app); + this.entries = entries ?? sharedFolder.getIgnoredRemoteEntries(); + this.setTitle("Remove ignored Relay entries"); + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("p", { + 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"); + 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(); + } +} 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;