diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 90d9c2ffd..c0c9bcdfc 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -181,6 +181,7 @@ const setupBackendServer = async (appPath: string, backendRootPath: string, user surfBackendManager.start() await surfBackendManager.waitForStart() + surfBackendManager.initWatcher() initializeSFFSMain() } diff --git a/app/src/main/ipcHandlers.ts b/app/src/main/ipcHandlers.ts index 87004e4a3..20a572bce 100644 --- a/app/src/main/ipcHandlers.ts +++ b/app/src/main/ipcHandlers.ts @@ -8,6 +8,7 @@ import { handleDragStart } from './drag' import { BrowserType, ElectronAppInfo, + ResourceFileChange, RightSidebarTab, SFFSResource, UserSettings @@ -716,5 +717,15 @@ export const ipcSenders = { } IPC_EVENTS_MAIN.updateViewBounds.sendToWebContents(window.webContents, { viewId, bounds }) + }, + + resourceFileChange(data: ResourceFileChange) { + const window = getMainWindow() + if (!window) { + log.error('Main window not found') + return + } + + IPC_EVENTS_MAIN.resourceFileChange.sendToWebContents(window.webContents, data) } } diff --git a/app/src/main/sffs.ts b/app/src/main/sffs.ts index 12c5f9fd9..e89dfa279 100644 --- a/app/src/main/sffs.ts +++ b/app/src/main/sffs.ts @@ -107,9 +107,26 @@ export class SFFSMain { const stringified = JSON.stringify(resource) - const result = this.sffs.js__store_update_resource(stringified) + const result = await this.sffs.js__store_update_resource(stringified) return result } + + async deleteResource(id: string) { + console.debug('deleting resource with id', id) + await this.sffs.js__store_remove_resources([id]) + } + + async getResourceByPath(path: string): Promise { + console.log('getting resource by path', path) + const dataString = await this.sffs.js__store_get_resource_by_path(path) + + const composite = optimisticParseJSON(dataString) + if (!composite) { + return null + } + + return SFFSMain.convertCompositeResourceToResource(composite) + } } let sffsMain: SFFSMain | null = null diff --git a/app/src/main/surfBackend.ts b/app/src/main/surfBackend.ts index bc62a921c..23513ec54 100644 --- a/app/src/main/surfBackend.ts +++ b/app/src/main/surfBackend.ts @@ -2,7 +2,10 @@ import { isWindows } from '@deta/utils' import { spawn, type ChildProcess, execSync } from 'child_process' import EventEmitter from 'events' -import { basename } from 'path' +import path, { basename } from 'path' +import { FileWatcher } from './watcher' +import { app } from 'electron' +import { ipcSenders } from './ipcHandlers' export class SurfBackendServerManager extends EventEmitter { private process: ChildProcess | null = null @@ -17,6 +20,8 @@ export class SurfBackendServerManager extends EventEmitter { private startResolve: (() => void) | null = null private startReject: ((reason: Error) => void) | null = null + private watcher: FileWatcher | null = null + constructor( private readonly serverPath: string, private readonly args: string[], @@ -29,6 +34,34 @@ export class SurfBackendServerManager extends EventEmitter { return this.lastKnownHealth } + initWatcher() { + const USER_DATA_PATH = app.getPath('userData') + const BACKEND_ROOT_PATH = path.join(USER_DATA_PATH, 'sffs_backend') + const BACKEND_RESOURCES_PATH = path.join(BACKEND_ROOT_PATH, 'resources') + + this.watcher = new FileWatcher(BACKEND_RESOURCES_PATH) + + this.watcher + .on('create', ({ filename, path }) => { + ipcSenders.resourceFileChange({ + type: 'create', + data: { newName: filename, newPath: path } + }) + }) + .on('delete', ({ filename, path }) => { + ipcSenders.resourceFileChange({ + type: 'delete', + data: { oldName: filename, oldPath: path } + }) + }) + .on('rename', ({ oldName, newName, oldPath, newPath }) => { + ipcSenders.resourceFileChange({ + type: 'rename', + data: { oldName, newName, oldPath, newPath } + }) + }) + } + start(): void { if (this.process) { this.emit('warn', 'surf backend server is already running') @@ -67,6 +100,10 @@ export class SurfBackendServerManager extends EventEmitter { return } + if (this.watcher) { + this.watcher.close() + } + this.isShuttingDown = true this.process.kill() this.process = null diff --git a/app/src/main/watcher.ts b/app/src/main/watcher.ts new file mode 100644 index 000000000..b5bcc1e05 --- /dev/null +++ b/app/src/main/watcher.ts @@ -0,0 +1,373 @@ +import fs from 'fs' +import path from 'path' + +interface FileWatcherOptions { + debounceDelay?: number + renameTimeout?: number + useInodeTracking?: boolean // Use inode for rename detection (Unix only) +} + +interface CreateEvent { + filename: string + path: string +} + +interface DeleteEvent { + filename: string + path: string +} + +interface RenameEvent { + oldName: string + newName: string + oldPath: string + newPath: string +} + +type EventCallback = (data: T) => void + +interface PendingEvent { + type: string + filename: string + timestamp: number +} + +export class FileWatcher { + private directory: string + private debounceDelay: number + private renameTimeout: number + private listeners: { + create: EventCallback[] + delete: EventCallback[] + rename: EventCallback[] + } + + private files: Map + private pendingEvents: PendingEvent[] + private debounceTimer: NodeJS.Timeout | null + private watcher: fs.FSWatcher | null + private isProcessing: boolean + private useInodeTracking: boolean + private inodeToFilename: Map + + constructor(directory: string, options: FileWatcherOptions = {}) { + this.directory = path.resolve(directory) + this.debounceDelay = options.debounceDelay || 100 + this.renameTimeout = options.renameTimeout || 50 + this.useInodeTracking = options.useInodeTracking !== false // Default true + this.listeners = { + create: [], + delete: [], + rename: [] + } + + this.files = new Map() + this.inodeToFilename = new Map() + this.pendingEvents = [] + this.debounceTimer = null + this.watcher = null + this.isProcessing = false + + this.initialize() + } + + private initialize(): void { + // Validate directory exists + if (!fs.existsSync(this.directory)) { + throw new Error(`Directory does not exist: ${this.directory}`) + } + + if (!fs.statSync(this.directory).isDirectory()) { + throw new Error(`Path is not a directory: ${this.directory}`) + } + + // Build initial file list with metadata + this.scanDirectory() + + // Start watching + try { + this.watcher = fs.watch(this.directory, (eventType, filename) => { + if (!filename) return + + this.pendingEvents.push({ + type: eventType, + filename, + timestamp: Date.now() + }) + + // Debounce event processing + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + } + + this.debounceTimer = setTimeout(() => { + this.processEvents() + }, this.debounceDelay) + }) + } catch (err) { + throw new Error(`Failed to watch directory: ${err}`) + } + } + + private scanDirectory(): void { + try { + const entries = fs.readdirSync(this.directory) + + for (const file of entries) { + const fullPath = path.join(this.directory, file) + try { + const stats = fs.statSync(fullPath) + if (stats.isFile()) { + const metadata = { + mtime: stats.mtimeMs, + size: stats.size, + ...(this.useInodeTracking && { ino: stats.ino }) + } + this.files.set(file, metadata) + + if (this.useInodeTracking && stats.ino !== undefined) { + this.inodeToFilename.set(stats.ino, file) + } + } + } catch (err) { + // File might have been deleted during scan, skip it + continue + } + } + } catch (err) { + throw new Error(`Error scanning directory: ${err}`) + } + } + + private async processEvents(): Promise { + // Prevent concurrent processing + if (this.isProcessing) { + return + } + + this.isProcessing = true + + try { + const events = [...this.pendingEvents] + this.pendingEvents = [] + + // Small delay to catch related events (e.g., rename operations) + await new Promise((resolve) => setTimeout(resolve, this.renameTimeout)) + + // Get current directory state + const currentFiles = new Map() + const currentInodeToFilename = new Map() + + try { + const entries = fs.readdirSync(this.directory) + + for (const file of entries) { + const fullPath = path.join(this.directory, file) + try { + const stats = fs.statSync(fullPath) + if (stats.isFile()) { + const metadata = { + mtime: stats.mtimeMs, + size: stats.size, + ...(this.useInodeTracking && { ino: stats.ino }) + } + currentFiles.set(file, metadata) + + if (this.useInodeTracking && stats.ino !== undefined) { + currentInodeToFilename.set(stats.ino, file) + } + } + } catch (err) { + // File might have been deleted during scan + continue + } + } + } catch (err) { + console.error('Error reading directory:', err) + this.isProcessing = false + return + } + + // Detect changes + const created: string[] = [] + const deleted: string[] = [] + const renamed: Array<{ oldName: string; newName: string }> = [] + + // Use inode tracking for accurate rename detection (Unix systems) + if (this.useInodeTracking) { + // Find files that changed names but kept same inode (renames) + for (const [oldFilename, oldMetadata] of this.files.entries()) { + if (oldMetadata.ino !== undefined) { + const newFilename = currentInodeToFilename.get(oldMetadata.ino) + + if (newFilename && newFilename !== oldFilename) { + // Same inode, different name = rename + renamed.push({ oldName: oldFilename, newName: newFilename }) + } else if (!newFilename) { + // Inode disappeared = delete + deleted.push(oldFilename) + } + // else: same filename, same inode = no change (not a create/delete) + } else if (!currentFiles.has(oldFilename)) { + // No inode info but file gone = delete + deleted.push(oldFilename) + } + } + + // Find truly new files (new inodes) + for (const [newFilename, newMetadata] of currentFiles.entries()) { + if (newMetadata.ino !== undefined) { + const wasRenamed = renamed.some((r) => r.newName === newFilename) + if (!this.inodeToFilename.has(newMetadata.ino) && !wasRenamed) { + // New inode = new file + created.push(newFilename) + } + } else if (!this.files.has(newFilename)) { + // No inode info but new name = create + created.push(newFilename) + } + } + } else { + // Fallback: name-based detection with size heuristic + // Find created files + for (const [filename, metadata] of currentFiles.entries()) { + if (!this.files.has(filename)) { + created.push(filename) + } + } + + // Find deleted files + for (const filename of this.files.keys()) { + if (!currentFiles.has(filename)) { + deleted.push(filename) + } + } + + // Try to match renames by size (less reliable) + if (created.length > 0 && deleted.length > 0) { + const pairs = this.matchRenames(deleted, created, currentFiles) + renamed.push(...pairs) + + // Remove matched pairs from created/deleted lists + const renamedOld = new Set(pairs.map((p) => p.oldName)) + const renamedNew = new Set(pairs.map((p) => p.newName)) + + created.splice(0, created.length, ...created.filter((f) => !renamedNew.has(f))) + deleted.splice(0, deleted.length, ...deleted.filter((f) => !renamedOld.has(f))) + } + } + + // Emit events + for (const { oldName, newName } of renamed) { + this.emit('rename', { + oldName, + newName, + oldPath: path.join(this.directory, oldName), + newPath: path.join(this.directory, newName) + }) + } + + for (const filename of created) { + this.emitCreate(filename, currentFiles.get(filename)!) + } + + for (const filename of deleted) { + this.emitDelete(filename) + } + + // Update tracked files and inodes + this.files = currentFiles + this.inodeToFilename = currentInodeToFilename + } finally { + this.isProcessing = false + } + } + + private matchRenames( + deleted: string[], + created: string[], + currentFiles: Map + ): Array<{ oldName: string; newName: string }> { + const pairs: Array<{ oldName: string; newName: string }> = [] + const usedDeleted = new Set() + const usedCreated = new Set() + + // Try to match by size (fallback when inode tracking unavailable) + // Note: This is unreliable and can produce false positives + for (const oldName of deleted) { + const oldMetadata = this.files.get(oldName) + if (!oldMetadata) continue + + for (const newName of created) { + if (usedCreated.has(newName)) continue + + const newMetadata = currentFiles.get(newName) + if (!newMetadata) continue + + // Match by size (weak indicator for renames) + // This can fail when: + // 1. Different files have same size + // 2. File is renamed AND content changes + if (oldMetadata.size === newMetadata.size) { + pairs.push({ oldName, newName }) + usedDeleted.add(oldName) + usedCreated.add(newName) + break + } + } + } + + return pairs + } + + private emitCreate(filename: string, metadata: { mtime: number; size: number }): void { + this.emit('create', { + filename, + path: path.join(this.directory, filename) + }) + } + + private emitDelete(filename: string): void { + this.emit('delete', { + filename, + path: path.join(this.directory, filename) + }) + } + + public on(event: 'create', callback: EventCallback): this + public on(event: 'delete', callback: EventCallback): this + public on(event: 'rename', callback: EventCallback): this + public on(event: string, callback: EventCallback): this { + if (this.listeners[event as keyof typeof this.listeners]) { + this.listeners[event as keyof typeof this.listeners].push(callback) + } + return this + } + + private emit(event: keyof typeof this.listeners, data: T): void { + const callbacks = this.listeners[event] + for (const callback of callbacks) { + try { + callback(data as any) + } catch (err) { + console.error(`Error in ${event} listener:`, err) + } + } + } + + public close(): void { + if (this.watcher) { + this.watcher.close() + this.watcher = null + } + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + + this.listeners.create = [] + this.listeners.delete = [] + this.listeners.rename = [] + } +} diff --git a/app/src/preload/core.ts b/app/src/preload/core.ts index 1029c5240..a242dfa7e 100644 --- a/app/src/preload/core.ts +++ b/app/src/preload/core.ts @@ -27,7 +27,8 @@ import { WebContentsViewManagerActionOutputs, WebContentsViewActionOutputs, RendererType, - type ControlWindow + type ControlWindow, + ResourceFileChange } from '@deta/types' import { @@ -480,6 +481,33 @@ const eventHandlers = { }) }, + onResourceFileChange: ( + callback: ( + type: ResourceFileChange['type'], + data: ResourceFileChange['data'], + file?: ResourceFileChange['file'] + ) => void + ) => { + return IPC_EVENTS_RENDERER.resourceFileChange.on(async (_, { type, data }) => { + try { + let file: File | undefined = undefined + + if (type === 'create' && data.newPath) { + const fileBuffer = await fsp.readFile(data.newPath) + const fileName = path.basename(data.newPath) + const fileType = mime.lookup(fileName.toLowerCase()) || 'application/octet-stream' + file = new File([fileBuffer as any], fileName, { + type: fileType + }) + } + + callback(type, data, file) + } catch (error) { + // noop + } + }) + }, + onWebContentsViewEvent: (callback: (event: WebContentsViewEvent) => void) => { return IPC_EVENTS_RENDERER.webContentsViewEvent.on((_, event) => { try { diff --git a/app/src/preload/helpers/backend.ts b/app/src/preload/helpers/backend.ts index 779b74db1..83d42c590 100644 --- a/app/src/preload/helpers/backend.ts +++ b/app/src/preload/helpers/backend.ts @@ -535,6 +535,10 @@ export class ResourceHandle { await this.fd.close() await this.onWriteHappened() } + + async delete(): Promise { + await fsp.unlink(this.filePath) + } } export const initResources = (sffs: ReturnType, opts?: SFFSOptions) => { @@ -590,6 +594,13 @@ export const initResources = (sffs: ReturnType, opts?: SFFSOpti resourceHandles.delete(resourceId) } + async function deleteResource(resourceId: string) { + const resourceHandle = resourceHandles.get(resourceId) + if (!resourceHandle) throw new Error('resource handle is not open') + + await resourceHandle.delete() + } + async function triggerPostProcessing(resourceId: string) { await (sffs as any).js__store_resource_post_process(resourceId) } @@ -620,6 +631,7 @@ export const initResources = (sffs: ReturnType, opts?: SFFSOpti writeResource, flushResource, closeResource, + deleteResource, updateResourceHash, triggerPostProcessing } diff --git a/app/src/renderer/Core/handlers/importerEvents.ts b/app/src/renderer/Core/handlers/importerEvents.ts index 450bbcaec..0c530845d 100644 --- a/app/src/renderer/Core/handlers/importerEvents.ts +++ b/app/src/renderer/Core/handlers/importerEvents.ts @@ -17,4 +17,77 @@ export const setupImportEvents = (events: PreloadEvents) => { log.error('Failed to import', err) } }) + + const ignorePaths = new Set() + + events.onResourceFileChange(async (type, data, file) => { + try { + log.debug('Resource file change detected', type, data, file) + if (type === 'rename') { + const { oldPath, newPath, newName } = data + if (oldPath && newPath) { + const resource = await resourceManager.getResourceByPath(oldPath) + if (resource) { + log.debug('Updating resource path for renamed file', resource.id, oldPath, newPath) + await resourceManager.updateResource(resource.id, { + resource_path: newPath, + updated_at: new Date().toISOString() + }) + + if (!resource.type.startsWith('application/vnd.space')) { + await resourceManager.updateResourceMetadata(resource.id, { + name: newName || resource.metadata.name + }) + } + + log.debug(`Updated resource path: ${oldPath} -> ${newPath}`) + } + } + } else if (type === 'delete') { + const { oldPath } = data + if (oldPath) { + const resource = await resourceManager.getResourceByPath(oldPath) + if (resource) { + log.debug('Deleting resource for deleted file', resource.id, oldPath) + await resourceManager.deleteResource(resource.id) + log.debug(`Deleted resource for file: ${oldPath}`) + } + } + } else if (type === 'create' && file) { + const { newPath } = data + + if (ignorePaths.has(newPath)) { + log.debug('Ignoring resource file change for path', newPath) + ignorePaths.delete(newPath) + return + } + + const existingResource = await resourceManager.getResourceByPath(newPath) + if (existingResource) { + log.debug('Resource already exists for created file, skipping', existingResource) + return + } + + const newResources = await createResourcesFromFiles([file], resourceManager) + const resource = newResources?.[0] + if (!resource) { + log.warn('No resource created for new file', newPath) + return + } + + const oldPath = resource.path + ignorePaths.add(oldPath) + + log.debug('Created resource for new file', resource) + + log.debug('Deleting temporary resource file and updating resource path', oldPath, newPath) + await resource.deleteDataFile() + await resourceManager.updateResource(resource.id, { + resource_path: newPath + }) + } + } catch (error) { + log.error('Error handling resource file change', error) + } + }) } diff --git a/packages/backend/src/api/message.rs b/packages/backend/src/api/message.rs index 5dabdad9d..3f2c41c0c 100644 --- a/packages/backend/src/api/message.rs +++ b/packages/backend/src/api/message.rs @@ -111,6 +111,7 @@ pub enum ResourceMessage { resource_metadata: Option, }, GetResource(String, bool), + GetResourceByPath(String), RemoveResources(Vec), RemoveResourcesByTags(Vec), RecoverResource(String), diff --git a/packages/backend/src/api/store.rs b/packages/backend/src/api/store.rs index 32fe3976c..d2363f4c8 100644 --- a/packages/backend/src/api/store.rs +++ b/packages/backend/src/api/store.rs @@ -6,6 +6,7 @@ use neon::types::JsDate; pub fn register_exported_functions(cx: &mut ModuleContext) -> NeonResult<()> { cx.export_function("js__store_create_resource", js_create_resource)?; cx.export_function("js__store_get_resource", js_get_resource)?; + cx.export_function("js__store_get_resource_by_path", js_get_resource_by_path)?; // cx.export_function("js__store_update_resource", js_update_resource)?; cx.export_function("js__store_remove_resources", js_remove_resources)?; cx.export_function( @@ -409,6 +410,19 @@ fn js_get_resource(mut cx: FunctionContext) -> JsResult { Ok(promise) } +fn js_get_resource_by_path(mut cx: FunctionContext) -> JsResult { + let tunnel = cx.argument::>(0)?; + let resource_path = cx.argument::(1)?.value(&mut cx); + + let (deferred, promise) = cx.promise(); + tunnel.worker_send_js( + WorkerMessage::ResourceMessage(ResourceMessage::GetResourceByPath(resource_path)), + deferred, + ); + + Ok(promise) +} + fn js_remove_resources(mut cx: FunctionContext) -> JsResult { let tunnel = cx.argument::>(0)?; let resource_ids = cx.argument::(1)?.to_vec(&mut cx)?; diff --git a/packages/backend/src/store/resources.rs b/packages/backend/src/store/resources.rs index ee44b2184..228d49f10 100644 --- a/packages/backend/src/store/resources.rs +++ b/packages/backend/src/store/resources.rs @@ -88,6 +88,22 @@ impl Database { .optional()?) } + pub fn get_resource_by_path(&self, path: &str) -> BackendResult> { + let mut stmt = self.conn.prepare("SELECT id, resource_path, resource_type, created_at, updated_at, deleted FROM resources WHERE resource_path = ?1")?; + Ok(stmt + .query_row(rusqlite::params![path], |row| { + Ok(Resource { + id: row.get(0)?, + resource_path: row.get(1)?, + resource_type: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + deleted: row.get(5)?, + }) + }) + .optional()?) + } + pub fn remove_resources_tx( tx: &mut rusqlite::Transaction, ids: &[String], diff --git a/packages/backend/src/worker/handlers/resource.rs b/packages/backend/src/worker/handlers/resource.rs index cd15596bd..d36055179 100644 --- a/packages/backend/src/worker/handlers/resource.rs +++ b/packages/backend/src/worker/handlers/resource.rs @@ -168,6 +168,29 @@ impl Worker { })) } + #[instrument(level = "trace", skip(self))] + fn read_resource_by_path(&mut self, path: &str) -> BackendResult> { + let resource = match self.db.get_resource_by_path(path)? { + Some(data) => data, + None => return Ok(None), + }; + let metadata = self.db.get_resource_metadata_by_resource_id(&resource.id)?; + let processing_state = self.db.get_resource_processing_state(&resource.id)?; + let space_ids = self.db.list_space_ids_by_resource_id(&resource.id)?; + let resource_tags = self.db.list_resource_tags(&resource.id)?; + let resource_tags = (!resource_tags.is_empty()).then_some(resource_tags); + + Ok(Some(CompositeResource { + resource, + metadata, + text_content: None, + resource_tags, + resource_annotations: None, + post_processing_job: processing_state, + space_ids: Some(space_ids), + })) + } + #[instrument(level = "trace", skip(self))] pub fn remove_resources(&mut self, ids: Vec) -> BackendResult<()> { if ids.is_empty() { @@ -741,6 +764,10 @@ pub fn handle_resource_message( let result = worker.read_resource(&id, include_annotations); send_worker_response(&mut worker.channel, oneshot, result); } + ResourceMessage::GetResourceByPath(path) => { + let result = worker.read_resource_by_path(&path); + send_worker_response(&mut worker.channel, oneshot, result); + } ResourceMessage::RemoveResources(ids) => { let result = worker.remove_resources(ids); send_worker_response(&mut worker.channel, oneshot, result); diff --git a/packages/services/src/lib/ipc/events.ts b/packages/services/src/lib/ipc/events.ts index 852035240..8d9e61451 100644 --- a/packages/services/src/lib/ipc/events.ts +++ b/packages/services/src/lib/ipc/events.ts @@ -13,7 +13,8 @@ import type { WebContentsViewEvent, WebContentsViewManagerActionEvent, WebContentsViewActionEvent, - ControlWindow + ControlWindow, + ResourceFileChange } from '@deta/types' import { createIPCService, type IPCEvent } from './ipc' @@ -180,6 +181,7 @@ const IPC_EVENTS = ipcService.registerEvents({ webContentsViewEvent: ipcService.addEvent('webcontentsview-event'), focusMainRenderer: ipcService.addEvent('focus-main-renderer'), updateViewBounds: ipcService.addEvent('update-view-bounds'), + resourceFileChange: ipcService.addEvent('resource-file-change'), // events that return a value getAdblockerState: ipcService.addEventWithReturn('get-adblocker-state'), diff --git a/packages/services/src/lib/notebooks/notebookManager.svelte.ts b/packages/services/src/lib/notebooks/notebookManager.svelte.ts index d66038979..483dba9c8 100644 --- a/packages/services/src/lib/notebooks/notebookManager.svelte.ts +++ b/packages/services/src/lib/notebooks/notebookManager.svelte.ts @@ -118,7 +118,7 @@ export class NotebookManager extends EventEmitterBase { - this.log.debug('Resource updated, refreshing affected notebooks:', resource.id) + console.log('Resource updated, refreshing affected notebooks:', resource.id) // Find notebooks that contain this resource and refresh their contents for (const notebook of this.notebooks.values()) { @@ -134,11 +134,12 @@ export class NotebookManager extends EventEmitterBase + useViewManager()?.viewsValue.forEach((view) => { + console.log('xxxx-sending resource update to view', view.id, resource.id) this.messagePort.extern_state_resourceUpdated.send(view.id, { resourceId: resource.id }) - ) + }) } else { this.messagePort.extern_state_resourceUpdated.send({ resourceId: resource.id diff --git a/packages/services/src/lib/resources/resources.svelte.ts b/packages/services/src/lib/resources/resources.svelte.ts index 10b0447d0..986f91900 100644 --- a/packages/services/src/lib/resources/resources.svelte.ts +++ b/packages/services/src/lib/resources/resources.svelte.ts @@ -549,6 +549,12 @@ export class Resource extends EventEmitterBase { } } + deleteDataFile() { + this.log.debug('deleting resource data file at', this.path) + + return this.sffs.deleteDataFile(this.id, this.type, this.path) + } + updateExtractionState(state: ResourceState) { this.extractionState.set(state) } @@ -754,7 +760,7 @@ export class ResourceManager extends EventEmitterBase, 'created_at' | 'updated_at' | 'deleted'> + data: Pick, 'created_at' | 'updated_at' | 'deleted' | 'resource_path'> ) { const resource = await this.getResource(id) if (!resource) { @@ -1328,6 +1346,10 @@ export class ResourceManager extends EventEmitterBase { + this.log.debug('getting resource by path', path) + const dataString = await this.backend.js__store_get_resource_by_path(path) + + const composite = this.parseData(dataString) + if (!composite) { + return null + } + + return this.convertCompositeResourceToResource(composite) + } + async updateResource(resource: SFFSRawResource) { this.log.debug('updating resource with id', resource.id, 'data:', resource) @@ -753,6 +765,19 @@ export class SFFS { await this.fs.closeResource(resourceId) } + async deleteDataFile( + resourceId: string, + resourceType: string, + resourcePath: string + ): Promise { + this.log.debug('deleting data file', resourceId, resourceType, resourcePath) + + await this.fs.openResource(resourceId, resourceType, resourcePath, 'w') + + await this.fs.deleteResource(resourceId) + await this.fs.closeResource(resourceId) + } + async createHistoryEntry(entry: HistoryEntry): Promise { this.log.debug('creating history entry', entry) const rawEntry = this.convertHistoryEntryToRawHistoryEntry(entry) diff --git a/packages/types/src/sffs.types.ts b/packages/types/src/sffs.types.ts index 495590de9..c7011028a 100644 --- a/packages/types/src/sffs.types.ts +++ b/packages/types/src/sffs.types.ts @@ -175,3 +175,14 @@ export type SpaceEntrySearchOptions = { order?: 'asc' | 'desc' limit?: number } + +export type ResourceFileChange = { + type: 'create' | 'delete' | 'rename' + data: { + oldName?: string + newName?: string + oldPath?: string + newPath?: string + } + file?: File +} diff --git a/yarn.lock b/yarn.lock index aa89e491f..14afd8f55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4019,9 +4019,9 @@ detect-node@^2.0.4: integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== devalue@^5.1.0, devalue@^5.3.2, devalue@^5.4.1: - version "5.4.1" - resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.4.1.tgz#6086910772fa055d707f60a3e5858d26ef9dcf55" - integrity sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ== + version "5.4.2" + resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.4.2.tgz#d002d836f9e92fc0c3bd9b76ea69129cbf99dca7" + integrity sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw== devlop@^1.0.0, devlop@^1.1.0: version "1.1.0" @@ -9554,7 +9554,16 @@ string-argv@~0.3.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9657,7 +9666,14 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10843,7 +10859,16 @@ word-wrap@^1.2.5: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==