Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ import { notifyDocumentBufferChanged } from '../store/buffer-registry';
import { waitForDocumentCommits } from '../store/document-commit-queue';
import { EditorView } from '@codemirror/view';
import { publishAgentLocationRequest, peekAgentLocationRequest } from '../use-cases/agent-location-navigation';
import * as attachmentIo from '@features/editor/extensions/attachment-link/upload/io';

const mocks = vi.hoisted(() => ({ recoveryRead: vi.fn().mockResolvedValue(null), read: vi.fn(), write: vi.fn(), rename: vi.fn(), publish: vi.fn() }));
vi.mock('@platform/tauri/core', () => ({ invoke: vi.fn(), convertFileSrc: (path: string) => `asset://localhost/${encodeURIComponent(path)}` }));
vi.mock('./lazy-document-editor', async () => ({
LazyDocumentEditor: (await import('@features/editor/markdown-editor')).MarkdownEditor,
preloadDocumentEditor: () => {},
}));
vi.mock('../use-cases/local-document-operations', () => ({
vi.mock('../use-cases/local-document-operations', async importOriginal => ({
...await importOriginal<typeof import('../use-cases/local-document-operations')>(),
markdownFilenameForTitle: (path: string, title: string) => {
const extension = path.match(/(\.markdown|\.md)$/i)?.[0] ?? '';
const trimmed = title.trim();
Expand All @@ -42,6 +44,17 @@ vi.mock('../use-cases/local-document-operations', () => ({
},
localDocumentOperations: { read: mocks.read, write: mocks.write, rename: mocks.rename },
}));
vi.mock('@platform/tauri/client/desktop', async importOriginal => {
const original = await importOriginal<typeof import('@platform/tauri/client/desktop')>();
return {
...original,
files: {
...original.files,
rename: async (path: string, name: string, scopePath: string) => (await mocks.rename({ path, name, scopePath })).path,
},
};
});
vi.mock('@features/memo/services/note-link-rewriter', () => ({ updateNoteLinksAfterMove: vi.fn() }));
vi.mock('@features/workspace/use-cases/workspace-navigation', () => ({ replaceExternalDocumentPath: (displayId: string, oldPath: string, path: string) => {
rebaseFileDisplayPath(oldPath, path, displayId); mocks.publish(path);
}, openExternalTarget: vi.fn().mockResolvedValue({ host: 'main-third', state: 'active' }) }));
Expand All @@ -54,6 +67,20 @@ vi.mock('@platform/tauri/window', () => ({ getCurrentWindow: () => ({ label: 'ma

let root: Root; let container: HTMLDivElement; let sequence = 0;
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };

function pasteScreenshot(editor: Editor): ClipboardEvent {
const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent;
Object.defineProperty(event, 'clipboardData', {
value: {
types: ['Files'],
files: [new File(['image'], 'Screenshot.png', { type: 'image/png' })],
getData: () => '',
},
});
editor.view.dom.dispatchEvent(event);
return event;
}

function setup(initialBoth = true) {
const identity = { kind: 'md' as const, ...ensureFileDisplayIdentity('/container-' + ++sequence + '/A.md') };
reconcileFileDisplays([identity]);
Expand Down Expand Up @@ -174,6 +201,75 @@ describe('real document container session lifecycle', () => {
container.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers();
environment.IS_REACT_ACT_ENVIRONMENT = false;
});
it('pastes a screenshot into its owning note when another notebook is selected', async () => {
const doc = setup(false);
const notebook = { id: 'owner', name: 'Notebook', path: doc.identity.path.replace('/A.md', ''), createdAt: 1, updatedAt: 1, isDefault: false };
const other = { ...notebook, id: 'other', path: '/another-notebook' };
useNoteStore.setState({ notebooks: [other, notebook], selectedNotebook: other });
vi.spyOn(attachmentIo, 'readFileBase64').mockResolvedValue('aW1hZ2U=');
const saved = `${notebook.path}/attachments/Screenshot.png`;
vi.mocked(invoke).mockImplementation(async command => {
if (command === 'begin_attachment_upload') return 'screenshot-upload';
if (command === 'finish_attachment_upload') return { path: saved, created: true };
});

await act(async () => { doc.render(); });
await act(async () => { expect(pasteScreenshot(doc.editors.left!).defaultPrevented).toBe(true); });

expect(invoke).toHaveBeenCalledWith('begin_attachment_upload', {
notePath: doc.identity.path, fileName: 'Screenshot.png', size: 5, deduplicate: true,
});
expect(invoke).toHaveBeenCalledWith('append_attachment_upload', {
uploadId: 'screenshot-upload', offset: 0, content: 'aW1hZ2U=',
});
expect(invoke).toHaveBeenCalledWith('finish_attachment_upload', { uploadId: 'screenshot-upload' });
expect(doc.editors.left!.getMarkdown()).toContain('Screenshot.png');
expect(doc.editors.left!.getMarkdown()).toContain(encodeURIComponent(saved));
});
it.each(['paste', 'insertFiles', 'drop'] as const)('uses the renamed note path for %s without remounting the editor', async method => {
const doc = setup(false);
const notebook = { id: 'owner', name: 'Notebook', path: doc.identity.path.replace('/A.md', ''), createdAt: 1, updatedAt: 1, isDefault: false };
useNoteStore.setState({ notebooks: [notebook], selectedNotebook: notebook });
vi.spyOn(attachmentIo, 'readFileBase64').mockResolvedValue('aW1hZ2U=');
const saved = `${notebook.path}/attachments/Screenshot.png`;
vi.mocked(invoke).mockImplementation(async command => {
if (command === 'begin_attachment_upload') return 'screenshot-upload';
if (command === 'finish_attachment_upload') return { path: saved, created: true };
});
await act(async () => { doc.render(); });
const editor = doc.editors.left!;
await act(async () => { doc.rename('Renamed'); await waitForDocumentCommits('md:' + doc.identity.displayId); });
const renamedPath = `${notebook.path}/Renamed.md`;
expect(findFileDisplayPath(doc.identity.displayId)).toBe(renamedPath);

await act(async () => {
if (method === 'paste') {
pasteScreenshot(editor);
return;
}
const files = [new File(['image'], 'Screenshot.png', { type: 'image/png' })];
if (method === 'insertFiles') {
expect(editor.commands.insertFiles({ files })).toBe(true);
return;
}
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: editor.state.selection.from, inside: -1 });
const event = new Event('drop', { bubbles: true, cancelable: true });
Object.defineProperties(event, {
dataTransfer: { value: { files, types: ['Files'], getData: () => '' } },
clientX: { value: 0 },
clientY: { value: 0 },
});
editor.view.dom.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});

expect(invoke).toHaveBeenCalledWith('begin_attachment_upload', {
notePath: renamedPath, fileName: 'Screenshot.png', size: 5, deduplicate: true,
});
expect(editor.getMarkdown()).toContain('Screenshot.png');
expect(doc.editors.left).toBe(editor);
expect(doc.mounts()).toBe(1);
});
it('inserts a picked image into a path-based note even when another notebook is selected', async () => {
const doc = setup(false);
const notebook = { id: 'owner', name: 'Notebook', path: doc.identity.path.replace('/A.md', ''), createdAt: 1, updatedAt: 1, isDefault: false };
Expand Down Expand Up @@ -217,9 +313,10 @@ describe('real document container session lifecycle', () => {
vi.stubGlobal('__TAURI__', {});
vi.mocked(invoke).mockResolvedValue(['/selected/fig.png']);
await act(async () => { doc.render(); });
vi.mocked(invoke).mockClear();
await act(async () => { doc.editors.left!.commands.openFileDialog(); });
// The watch lease is unrelated lifecycle noise; assert no asset was saved.
expect(invoke).not.toHaveBeenCalledWith('save_notebook_asset', expect.anything());
expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual(['select_files']);
expect(doc.editors.left!.getMarkdown()).toBe('Body');
});
it('opening a second view preserves scheduled autosave', async () => {
Expand Down Expand Up @@ -272,9 +369,10 @@ describe('real document container session lifecycle', () => {
await act(async () => { await vi.advanceTimersByTimeAsync(300); });
expect(findFileDisplayPath(doc.identity.displayId)).toBe(doc.identity.path);
expect(mocks.write).toHaveBeenLastCalledWith(expect.objectContaining({ path: doc.identity.path, content: expect.stringContaining('retained body') }));
expect((container.querySelector('textarea.memo-title-editor') as HTMLTextAreaElement).value).toBe('A');
expect(getDocumentSession(doc.identity).restoredTitle?.draft).toBe('Taken');
// A rejected rename restores the confirmed title and keeps the attempted
// title recoverable through an explicit action.
expect((container.querySelector('textarea.memo-title-editor') as HTMLTextAreaElement).value).toBe('A');
expect(container.textContent).toContain('Recover unsaved title');
expect(doc.mounts()).toBe(2);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ export function DocumentContainer({
const getAttachmentNotebookId = useCallback(() => {
return resolveNotebookIdForPath(documentIdentity.path, getWorkspaceMemoState().notebooks);
}, [documentIdentity]);
const getAttachmentOwnerPath = useCallback(() => {
// COSEC: Uploads must belong to a note inside a registered notebook.
return getAttachmentNotebookId() ? getCurrentFilePath() : undefined;
}, [getAttachmentNotebookId, getCurrentFilePath]);
const {
state,
setState,
Expand Down Expand Up @@ -518,6 +522,7 @@ export function DocumentContainer({
)}
{!state.isLoading && state.isLoaded && !usesCodeEditor && (
<LazyDocumentEditor
getAttachmentOwnerPath={getAttachmentOwnerPath}
getAttachmentNotebookId={getAttachmentNotebookId}
propertyTargetId={propertyTargetId}
onViewSourceMode={handleToggleEditorMode}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const AttachmentLink = Extension.create<AttachmentUploadOptions>({
const opts = normalizeOptions(this.options);
return [
createFileUploadPlugin({
ownerPath: this.options.ownerPath,
getOwnerPath: () => this.options.getOwnerPath ? this.options.getOwnerPath() : this.options.ownerPath,
ingest: {
drop: opts.ingest.drop,
paste: false,
Expand All @@ -61,7 +61,10 @@ export const AttachmentLink = Extension.create<AttachmentUploadOptions>({
},

addCommands() {
return createAttachmentCommands(this.options.ownerPath, this.options.getNotebookId);
return createAttachmentCommands(
() => this.options.getOwnerPath ? this.options.getOwnerPath() : this.options.ownerPath,
this.options.getNotebookId,
);
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('chunked attachment transport', () => {
it('slices input and waits for each chunk before committing', async () => {
const file = new File([new Uint8Array(UPLOAD_CHUNK_BYTES + 1)], 'file');
expect(await uploadInChunks(file, 'file', 'memo')).toEqual({ path: '/attachments/file', created: true });
expect(invoke).toHaveBeenNthCalledWith(1, 'begin_attachment_upload', { notePath: 'memo', fileName: 'file', size: file.size });
expect(vi.mocked(readFileBase64).mock.calls.map(([blob]) => blob.size)).toEqual([UPLOAD_CHUNK_BYTES, 1]);
expect(invoke).toHaveBeenNthCalledWith(2, 'append_attachment_upload', { uploadId: 'upload', offset: 0, content: 'YQ==' });
expect(invoke).toHaveBeenNthCalledWith(3, 'append_attachment_upload', { uploadId: 'upload', offset: UPLOAD_CHUNK_BYTES, content: 'YQ==' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function uploadInChunks(
};
checkCancelled();
const uploadId = await invoke<string>('begin_attachment_upload', {
ownerPath,
notePath: ownerPath,
fileName,
size: file.size,
...(deduplicate ? { deduplicate: true } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ function pickBrowserFiles(params: OpenFileDialogParams | undefined, signal: Abor
});
}

export function createAttachmentCommands(ownerPath?: string, getNotebookId?: () => string | undefined): Partial<RawCommands> {
export function createAttachmentCommands(getOwnerPath?: () => string | undefined, getNotebookId?: () => string | undefined): Partial<RawCommands> {
return {
openFileDialog:
(params?: OpenFileDialogParams) =>
({ editor }: { editor: Editor }) => {
if (editor.isDestroyed || !editor.isEditable) return false;
const ownerPath = getOwnerPath?.();
const notebookId = getNotebookId?.();
void runTrackedUpload(editor.view, async (signal) => {
if (isTauriApp()) {
Expand All @@ -62,7 +63,7 @@ export function createAttachmentCommands(ownerPath?: string, getNotebookId?: ()
(params: { files: File[]; position?: number }) =>
({ editor }: { editor: Editor }) => {
if (editor.isDestroyed || !editor.isEditable) return false;
void handleFileUpload(editor.view, params.files, params.position, undefined, ownerPath);
void handleFileUpload(editor.view, params.files, params.position, undefined, getOwnerPath?.());
return true;
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type AttachmentSaveResult = {

export type AttachmentUploadOptions = {
ownerPath?: string;
getOwnerPath?: () => string | undefined;
getNotebookId?: () => string | undefined;
storage: { mode: 'attachment' };
picker: { accept: string | undefined; multiple: boolean };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export async function handleFileUpload(
}

export function createFileUploadPlugin(options: {
ownerPath?: string;
getOwnerPath?: () => string | undefined;
ingest: { drop: boolean; paste: boolean; allowedMimeTypes?: string[] };
}) {
const { ingest } = options;
Expand All @@ -48,7 +48,7 @@ export function createFileUploadPlugin(options: {
event.stopPropagation();
const coords = { left: event.clientX, top: event.clientY };
const pos = view.posAtCoords(coords)?.pos;
handleFileUpload(view, filteredFiles, pos, undefined, options.ownerPath);
handleFileUpload(view, filteredFiles, pos, undefined, options.getOwnerPath?.());
return true;
},
handlePaste(view, event) {
Expand All @@ -64,7 +64,7 @@ export function createFileUploadPlugin(options: {
event.preventDefault();
event.stopPropagation();
const pos = view.state.selection.from;
handleFileUpload(view, files, pos, undefined, options.ownerPath);
handleFileUpload(view, files, pos, undefined, options.getOwnerPath?.());
return true;
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { hasImportableHtml, isInternalEditorHtml, sanitizeExternalHtml } from '@
import type { PasteContext } from '@features/editor/extensions/paste-rules/types';
import type { Editor } from '@tiptap/core';

export const ManagedPasteRules = Extension.create<{ ownerPath?: string }>({
export const ManagedPasteRules = Extension.create<{ ownerPath?: string; getOwnerPath?: () => string | undefined }>({
name: 'managedPasteRules',
priority: 1100,

Expand All @@ -33,15 +33,16 @@ export const ManagedPasteRules = Extension.create<{ ownerPath?: string }>({
const clipboardData = event.clipboardData;
if (!clipboardData) return false;
const snapshot = readClipboardSnapshot(clipboardData);
const ownerPath = this.options.getOwnerPath ? this.options.getOwnerPath() : this.options.ownerPath;
if (snapshot.html && !hasImportableHtml(snapshot.html) && snapshot.text) {
event.preventDefault();
return pasteClipboardSnapshot(this.editor, { ...snapshot, html: '' }, this.options.ownerPath);
return pasteClipboardSnapshot(this.editor, { ...snapshot, html: '' }, ownerPath);
}

const ctx: PasteContext = {
editor: this.editor,
view,
ownerPath: this.options.ownerPath,
ownerPath,
event,
types: snapshot.types,
markdown: snapshot.markdown,
Expand Down
Loading