Skip to content

Commit dacbb7e

Browse files
Improve file drop behavior: separate regular vs edit mode handling
Regular mode drops: - Files dropped on DreamNodes are now added to repo without updating dreamTalk - Treats file drops like dropping files on a folder Edit mode drops: - Only accepts valid media files (images/videos) for dreamTalk - Detects internal files (already in repo) by attempting to read them - Compares file hashes to avoid unnecessary copies - Loads data URL immediately for proper display after save 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 04c20b4 commit dacbb7e

6 files changed

Lines changed: 222 additions & 24 deletions

File tree

src/dreamspace/DreamspaceCanvas.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,14 +1066,19 @@ export default function DreamspaceCanvas() {
10661066

10671067
const handleDropOnNode = async (files: globalThis.File[], node: DreamNode) => {
10681068
try {
1069-
1070-
// Use service to add files to existing node
10711069
const service = serviceManager.getActive();
1072-
await service.addFilesToNode(node.id, files);
1073-
1070+
1071+
// In regular mode (not edit mode), just add files without updating dreamTalk
1072+
// This treats file drops like dropping files on a folder
1073+
if (service.addFilesToNodeWithoutDreamTalkUpdate) {
1074+
await service.addFilesToNodeWithoutDreamTalkUpdate(node.id, files);
1075+
uiService.showSuccess(`Added ${files.length} file(s) to "${node.name}"`);
1076+
} else {
1077+
// Fallback for services that don't support the new method
1078+
await service.addFilesToNode(node.id, files);
1079+
}
1080+
10741081
// No need to manually refresh - event listener will handle it
1075-
1076-
10771082
} catch (error) {
10781083
console.error('Failed to add files to node:', error);
10791084
uiService.showError(error instanceof Error ? error.message : 'Failed to add files to node');

src/features/edit-mode/EditModeOverlay.tsx

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import fs from 'fs';
23
import { useInterBrainStore } from '../../store/interbrain-store';
34
import { serviceManager } from '../../services/service-manager';
45
import { UIService } from '../../services/ui-service';
@@ -59,11 +60,113 @@ export default function EditModeOverlay() {
5960

6061
// Get the active service for persistence
6162
const dreamNodeService = serviceManager.getActive();
62-
63+
6364
// 1. Handle new DreamTalk media file if provided
6465
if (editMode.newDreamTalkFile) {
65-
console.log(`EditModeOverlay: Saving new DreamTalk media: ${editMode.newDreamTalkFile.name}`);
66-
await dreamNodeService.addFilesToNode(editMode.editingNode.id, [editMode.newDreamTalkFile]);
66+
const file = editMode.newDreamTalkFile;
67+
68+
// Try to read the file - if it fails, it's likely an internal file already in the repo
69+
let fileIsReadable = false;
70+
let fileHash: string | null = null;
71+
72+
try {
73+
const buffer = await file.arrayBuffer();
74+
fileIsReadable = true;
75+
// Calculate hash of the dropped file
76+
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
77+
fileHash = Array.from(new Uint8Array(hashBuffer))
78+
.map(b => b.toString(16).padStart(2, '0'))
79+
.join('');
80+
} catch {
81+
// File not readable - it's an internal file reference
82+
fileIsReadable = false;
83+
}
84+
85+
if (!fileIsReadable) {
86+
// Internal file - just update the dreamTalk path in metadata, no file copy needed
87+
console.log(`EditModeOverlay: File not readable, setting existing file as DreamTalk: ${file.name}`);
88+
89+
// Load the file data from disk for immediate display
90+
const vaultService = serviceManager.getVaultService();
91+
const targetPath = `${editMode.editingNode.repoPath}/${file.name}`;
92+
let dataUrl = '';
93+
let fileSize = 0;
94+
95+
if (vaultService) {
96+
try {
97+
dataUrl = await vaultService.readFileAsDataURL(targetPath);
98+
const fullPath = vaultService.getFullPath(targetPath);
99+
const stats = fs.statSync(fullPath);
100+
fileSize = stats.size;
101+
} catch (err) {
102+
console.warn(`EditModeOverlay: Could not load file data for preview: ${err}`);
103+
}
104+
}
105+
106+
const updates: Partial<DreamNode> = {
107+
dreamTalkMedia: [{
108+
path: file.name,
109+
absolutePath: targetPath,
110+
type: file.type || 'application/octet-stream',
111+
data: dataUrl,
112+
size: fileSize
113+
}]
114+
};
115+
await dreamNodeService.update(editMode.editingNode.id, updates);
116+
} else if (fileHash) {
117+
// File is readable - check if it already exists in the repo by comparing hashes
118+
const vaultService = serviceManager.getVaultService();
119+
if (vaultService) {
120+
const targetPath = `${editMode.editingNode.repoPath}/${file.name}`;
121+
const existingFileExists = await vaultService.fileExists(targetPath);
122+
123+
if (existingFileExists) {
124+
// File exists - compare hashes using Node.js fs
125+
const fullPath = vaultService.getFullPath(targetPath);
126+
const existingContent = fs.readFileSync(fullPath);
127+
const existingHashBuffer = await crypto.subtle.digest('SHA-256', existingContent);
128+
const existingHash = Array.from(new Uint8Array(existingHashBuffer))
129+
.map(b => b.toString(16).padStart(2, '0'))
130+
.join('');
131+
132+
if (existingHash === fileHash) {
133+
// Same file - just update the metadata reference, no copy needed
134+
console.log(`EditModeOverlay: File already exists with same hash, updating reference: ${file.name}`);
135+
136+
// Load the file data from disk for immediate display
137+
let dataUrl = '';
138+
try {
139+
dataUrl = await vaultService.readFileAsDataURL(targetPath);
140+
} catch (err) {
141+
console.warn(`EditModeOverlay: Could not load file data for preview: ${err}`);
142+
}
143+
144+
const updates: Partial<DreamNode> = {
145+
dreamTalkMedia: [{
146+
path: file.name,
147+
absolutePath: targetPath,
148+
type: file.type || 'application/octet-stream',
149+
data: dataUrl,
150+
size: file.size
151+
}]
152+
};
153+
await dreamNodeService.update(editMode.editingNode.id, updates);
154+
} else {
155+
// Different file with same name - copy and update
156+
console.log(`EditModeOverlay: File exists but different hash, replacing: ${file.name}`);
157+
await dreamNodeService.addFilesToNode(editMode.editingNode.id, [file]);
158+
}
159+
} else {
160+
// File doesn't exist - copy to repo
161+
console.log(`EditModeOverlay: Saving new DreamTalk media: ${file.name}`);
162+
await dreamNodeService.addFilesToNode(editMode.editingNode.id, [file]);
163+
}
164+
} else {
165+
// No vault service - fall back to direct save
166+
console.log(`EditModeOverlay: Saving new DreamTalk media (no vault service): ${file.name}`);
167+
await dreamNodeService.addFilesToNode(editMode.editingNode.id, [file]);
168+
}
169+
}
67170
}
68171

69172
// 2. Save metadata changes (let service layer handle if no changes)

src/features/edit-mode/EditNode3D.tsx

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -236,18 +236,39 @@ export default function EditNode3D({
236236
e.preventDefault();
237237
e.stopPropagation();
238238
setIsDragOver(false);
239-
239+
240240
const files = Array.from(e.dataTransfer.files);
241241
const file = files[0];
242-
243-
if (file && isValidMediaFile(file)) {
244-
console.log(`[EditNode3D] New DreamTalk media dropped: ${file.name}`);
242+
243+
if (!file) {
244+
return;
245+
}
246+
247+
// Validate media file type first
248+
if (!isValidMediaFile(file)) {
249+
console.log(`[EditNode3D] Dropped file is not valid DreamTalk media: ${file.name} (${file.type})`);
250+
return;
251+
}
252+
253+
console.log(`[EditNode3D] New DreamTalk media dropped: ${file.name}`);
254+
255+
// Try to create preview URL - this works for both internal and external files
256+
try {
245257
const previewUrl = globalThis.URL.createObjectURL(file);
246258
setPreviewMedia(previewUrl);
247-
248-
// Store the new file in edit mode state for save processing
249-
setEditModeNewDreamTalkFile(file);
259+
} catch {
260+
// If preview fails, try to use existing media data
261+
if (editingNode) {
262+
const existingMedia = editingNode.dreamTalkMedia.find(m => m.path === file.name);
263+
if (existingMedia && existingMedia.data) {
264+
setPreviewMedia(existingMedia.data);
265+
}
266+
}
250267
}
268+
269+
// Store the file reference - the save handler will detect if it's internal/external
270+
// and handle hash comparison to avoid unnecessary file copies
271+
setEditModeNewDreamTalkFile(file);
251272
};
252273

253274
const handleFileSelect = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => {
@@ -756,7 +777,8 @@ export default function EditNode3D({
756777
}
757778

758779
/**
759-
* Validate media file types for DreamTalk (same as ProtoNode3D)
780+
* Validate media file types for DreamTalk
781+
* Only allows actual visual media: images and videos (NOT PDFs or text files like .md)
760782
*/
761783
function isValidMediaFile(file: globalThis.File): boolean {
762784
const validTypes = [
@@ -765,18 +787,30 @@ function isValidMediaFile(file: globalThis.File): boolean {
765787
'image/jpg',
766788
'image/gif',
767789
'image/webp',
790+
'image/svg+xml',
768791
'video/mp4',
769-
'video/webm',
770-
// .link files may appear as text/plain or application/octet-stream
771-
'text/plain',
772-
'application/octet-stream'
792+
'video/webm'
773793
];
774794

775-
// Check file extension as fallback for unreliable MIME types
795+
// Check file extension for additional image/video types that might have unreliable MIME types
776796
const fileName = file.name.toLowerCase();
797+
const validExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.mp4', '.webm', '.link'];
798+
799+
// .link files are special case (URL references)
777800
if (fileName.endsWith('.link')) {
778801
return true;
779802
}
780803

781-
return validTypes.includes(file.type);
804+
// Check MIME type first
805+
if (validTypes.includes(file.type)) {
806+
return true;
807+
}
808+
809+
// Fallback: check extension for files with application/octet-stream MIME type
810+
// Only allow known image/video extensions
811+
if (file.type === 'application/octet-stream') {
812+
return validExtensions.some(ext => fileName.endsWith(ext));
813+
}
814+
815+
return false;
782816
}

src/services/git-dreamnode-service.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1313,7 +1313,40 @@ export class GitDreamNodeService {
13131313

13141314
console.log(`GitDreamNodeService: Added ${files.length} files to ${nodeId}`);
13151315
}
1316-
1316+
1317+
/**
1318+
* Add files to an existing DreamNode WITHOUT updating dreamTalk
1319+
* Used for regular mode file drops where files should just be added to the repo
1320+
*/
1321+
async addFilesToNodeWithoutDreamTalkUpdate(nodeId: string, files: globalThis.File[]): Promise<void> {
1322+
const store = useInterBrainStore.getState();
1323+
const nodeData = store.realNodes.get(nodeId);
1324+
1325+
if (!nodeData) {
1326+
throw new Error(`DreamNode with ID ${nodeId} not found`);
1327+
}
1328+
1329+
const node = nodeData.node;
1330+
const repoPath = path.join(this.vaultPath, node.repoPath);
1331+
1332+
// Write all files to disk without updating dreamTalk
1333+
for (const file of files) {
1334+
const buffer = await file.arrayBuffer();
1335+
await fsPromises.writeFile(
1336+
path.join(repoPath, file.name),
1337+
globalThis.Buffer.from(buffer)
1338+
);
1339+
}
1340+
1341+
// Update last synced time (no UDD update needed since dreamTalk isn't changing)
1342+
store.updateRealNode(nodeId, {
1343+
...nodeData,
1344+
lastSynced: Date.now()
1345+
});
1346+
1347+
console.log(`GitDreamNodeService: Added ${files.length} files to ${nodeId} (without dreamTalk update)`);
1348+
}
1349+
13171350
private isMediaFile(file: globalThis.File): boolean {
13181351
const validTypes = [
13191352
'image/png',

src/services/mock-dreamnode-service.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,28 @@ export class MockDreamNodeService {
191191
});
192192
}
193193

194+
/**
195+
* Add files to an existing DreamNode WITHOUT updating dreamTalk
196+
* Used for regular mode file drops where files should just be added to the repo
197+
*/
198+
async addFilesToNodeWithoutDreamTalkUpdate(nodeId: string, files: globalThis.File[]): Promise<void> {
199+
const node = this.nodes.get(nodeId);
200+
if (!node) {
201+
throw new Error(`DreamNode with ID ${nodeId} not found`);
202+
}
203+
204+
const existingFiles = this.repositoryFiles.get(nodeId) || [];
205+
206+
// Add all files to repository without updating dreamTalk
207+
const updatedFiles = [...existingFiles, ...files];
208+
this.repositoryFiles.set(nodeId, updatedFiles);
209+
210+
console.log(`MockDreamNodeService: Added ${files.length} files to ${nodeId} (without dreamTalk update):`, {
211+
files: files.map(f => `${f.name} (${f.type})`).join(', '),
212+
totalRepoFiles: updatedFiles.length
213+
});
214+
}
215+
194216
/**
195217
* Check if a file is a media file (image or video)
196218
*/

src/services/service-manager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface IDreamNodeService {
2626
list(): Promise<DreamNode[]>;
2727
get(id: string): Promise<DreamNode | null>;
2828
addFilesToNode(nodeId: string, files: globalThis.File[]): Promise<void>;
29+
addFilesToNodeWithoutDreamTalkUpdate?(nodeId: string, files: globalThis.File[]): Promise<void>;
2930
reset(): void;
3031
getStats(): {
3132
totalNodes: number;

0 commit comments

Comments
 (0)