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
189 changes: 181 additions & 8 deletions src/app/routes/NoteEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NoteEditor } from "@/app/routes/NoteEditor";
import { useToastStore } from "@/lib/toast/store";
import { useVaultStore } from "@/lib/vault/store";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
Expand All @@ -8,14 +9,102 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Swap CodeMirror out for a plain textarea so tests can drive onChange without
// wrangling CM6 inside jsdom.
vi.mock("@/components/CodeMirrorEditor", () => ({
CodeMirrorEditor: ({
value,
onChange,
}: { value: string; onChange(next: string): void; onSave?(): void; onCancel?(): void }) => (
<textarea data-testid="cm-editor" value={value} onChange={(e) => onChange(e.target.value)} />
),
}));
vi.mock("@/components/CodeMirrorEditor", async () => {
const React = await import("react");
return {
CodeMirrorEditor: React.forwardRef(function MockCodeMirrorEditor(
props: {
value: string;
onChange(next: string): void;
onSave?(): void;
onCancel?(): void;
onPasteFile?(files: File[]): boolean;
},
ref: React.Ref<{ insertAtCursor(s: string): void; focus(): void }>,
) {
const { value, onChange, onPasteFile } = props;
React.useImperativeHandle(
ref,
() => ({
insertAtCursor(s: string) {
onChange(value + s);
},
focus() {},
}),
[value, onChange],
);
return (
<>
<textarea
data-testid="cm-editor"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<button
type="button"
data-testid="cm-paste-image"
onClick={() => {
const f = new File([new Uint8Array([1, 2])], "pasted.png", { type: "image/png" });
onPasteFile?.([f]);
}}
>
mock paste
</button>
</>
);
}),
};
});

class FakeXhrUpload {
onprogress: ((e: ProgressEvent) => void) | null = null;
}

class FakeXhr {
method = "";
url = "";
body: Document | XMLHttpRequestBodyInit | null = null;
status = 0;
responseText = "";
headers: Record<string, string> = {};
upload = new FakeXhrUpload();
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
onabort: (() => void) | null = null;

open(method: string, url: string) {
this.method = method;
this.url = url;
}
setRequestHeader(k: string, v: string) {
this.headers[k] = v;
}
send(body: Document | XMLHttpRequestBodyInit | null) {
this.body = body;
}
abort() {
this.onabort?.();
}
resolve(status: number, body: string) {
this.status = status;
this.responseText = body;
this.onload?.();
}
}

function installXhr(): FakeXhr[] {
const xhrs: FakeXhr[] = [];
vi.stubGlobal(
"XMLHttpRequest",
// biome-ignore lint/complexity/useArrowFunction: must be `new`-able
function () {
const x = new FakeXhr();
xhrs.push(x);
return x;
} as unknown as typeof XMLHttpRequest,
);
return xhrs;
}

interface FetchEntry {
status?: number;
Expand Down Expand Up @@ -107,6 +196,7 @@ describe("NoteEditor route", () => {
beforeEach(() => {
localStorage.clear();
useVaultStore.setState({ vaults: {}, activeVaultId: null });
useToastStore.setState({ toasts: [] });
seedStore();
// jsdom doesn't implement confirm; default it to true so paths that gate
// on user approval proceed.
Expand Down Expand Up @@ -224,4 +314,87 @@ describe("NoteEditor route", () => {
fireEvent.change(pathInput, { target: { value: "Canon/Aaron-v2" } });
expect(screen.getByText(/renaming moves the note/i)).toBeInTheDocument();
});

it("drop file → uploads, inserts markdown, and links to the existing note", async () => {
const fetchImpl = installFetch({
"GET /api/notes": { body: baseNote },
"POST /api/notes/abc-123/attachments": {
status: 201,
body: {
id: "att-1",
noteId: "abc-123",
path: "2026-04-18/shot.png",
mimeType: "image/png",
},
},
});
const xhrs = installXhr();
renderAt("/notes/abc-123/edit");

const cm = await screen.findByTestId("cm-editor");
const dropZone = cm.closest("div.relative");
expect(dropZone).not.toBeNull();

const file = new File([new Uint8Array([1, 2, 3])], "shot.png", { type: "image/png" });
const dataTransfer = {
files: [file],
items: [{ kind: "file" }],
types: ["Files"],
dropEffect: "copy",
} as unknown as DataTransfer;

fireEvent.drop(dropZone!, { dataTransfer });

await waitFor(() => expect(xhrs.length).toBe(1));
expect(xhrs[0]!.url).toBe("http://localhost:1940/api/storage/upload");

await act(async () => {
xhrs[0]!.resolve(
201,
JSON.stringify({ path: "2026-04-18/shot.png", size: 3, mimeType: "image/png" }),
);
});

await waitFor(() => {
expect((cm as HTMLTextAreaElement).value).toContain(
"![shot.png](/api/storage/2026-04-18/shot.png)",
);
});

await waitFor(() => {
const linkCall = fetchImpl.mock.calls.find(([url, init]) => {
const u = typeof url === "string" ? url : url.toString();
return (
u.includes("/api/notes/abc-123/attachments") &&
(init as RequestInit | undefined)?.method === "POST"
);
});
expect(linkCall).toBeDefined();
});
});

it("oversized file is rejected before any upload fires", async () => {
installFetch({ "GET /api/notes": { body: baseNote } });
const xhrs = installXhr();
renderAt("/notes/abc-123/edit");

const cm = await screen.findByTestId("cm-editor");
const dropZone = cm.closest("div.relative");

const big = new File([new Uint8Array([1])], "huge.png", { type: "image/png" });
Object.defineProperty(big, "size", { value: 200 * 1024 * 1024 });

const dataTransfer = {
files: [big],
items: [{ kind: "file" }],
types: ["Files"],
} as unknown as DataTransfer;
fireEvent.drop(dropZone!, { dataTransfer });

expect(xhrs.length).toBe(0);
await waitFor(() => {
const toasts = useToastStore.getState().toasts;
expect(toasts.some((t) => /too large/i.test(t.message))).toBe(true);
});
});
});
102 changes: 99 additions & 3 deletions src/app/routes/NoteEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { AttachmentDropZone } from "@/components/AttachmentDropZone";
import { AttachmentPicker } from "@/components/AttachmentPicker";
import { AttachmentUploadList } from "@/components/AttachmentUploadList";
import type { CodeMirrorEditorHandle } from "@/components/CodeMirrorEditor";
import { CodeMirrorEditor } from "@/components/CodeMirrorEditor";
import { DeleteNoteButton } from "@/components/DeleteNoteButton";
import { MarkdownView, buildWikilinkResolver } from "@/components/MarkdownView";
import { TagEditor, normalizeTag } from "@/components/TagEditor";
import { useAttachmentUploader } from "@/components/useAttachmentUploader";
import { relativeTime } from "@/lib/time";
import { useToastStore } from "@/lib/toast/store";
import { useNote, useUpdateNote, useVaultStore } from "@/lib/vault";
import { type UpdateNotePayload, VaultAuthError, VaultConflictError } from "@/lib/vault/client";
import type { Note } from "@/lib/vault/types";
import type { Note, NoteAttachment } from "@/lib/vault/types";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, Navigate, useNavigate, useParams } from "react-router";

Expand Down Expand Up @@ -56,6 +62,7 @@ function toEditorState(note: Note): EditorState {

function EditorSurface({ note }: { note: Note }) {
const navigate = useNavigate();
const pushToast = useToastStore((s) => s.push);
const resolver = useMemo(() => buildWikilinkResolver(note), [note]);
const [baseline, setBaseline] = useState<EditorState>(() => toEditorState(note));
const [draft, setDraft] = useState<EditorState>(() => toEditorState(note));
Expand All @@ -64,6 +71,22 @@ function EditorSurface({ note }: { note: Note }) {
const [saveError, setSaveError] = useState<string | null>(null);
const mutation = useUpdateNote(note.id);
const lastServerNote = useRef<Note>(note);
const editorRef = useRef<CodeMirrorEditorHandle>(null);

const uploader = useAttachmentUploader({
noteId: note.id,
onInsert: (md) => {
if (editorRef.current) {
editorRef.current.insertAtCursor(md);
} else {
setDraft((d) => ({ ...d, content: `${d.content}${md}` }));
}
},
onLinked: () => {
pushToast("Attachment added", "success");
},
onError: (msg) => pushToast(msg, "error"),
});

// If the server-side note is refetched (e.g., after a background refresh),
// only update baseline if the user has no in-flight changes.
Expand Down Expand Up @@ -241,22 +264,95 @@ function EditorSurface({ note }: { note: Note }) {
) : null}

<div className="grid min-h-[60vh] gap-4 lg:grid-cols-2">
<div className="min-w-0 rounded-md border border-border bg-card">
<AttachmentDropZone
onDropFiles={uploader.start}
className="min-w-0 rounded-md border border-border bg-card"
hint={ALLOWLIST_HINT}
>
<CodeMirrorEditor
ref={editorRef}
value={draft.content}
onChange={(content) => setDraft((d) => ({ ...d, content }))}
onSave={handleSave}
onCancel={handleCancel}
onPasteFile={(files) => {
uploader.start(files);
return true;
}}
/>
</div>
</AttachmentDropZone>
<div className="min-w-0 overflow-auto rounded-md border border-border bg-card p-4">
<MarkdownView content={previewContent} resolve={resolver} />
</div>
</div>

<AttachmentsSection
attachments={note.attachments ?? []}
uploads={uploader.uploads}
onPickFiles={uploader.start}
onCancel={uploader.cancel}
onDismiss={uploader.dismiss}
/>
</article>
);
}

const ALLOWLIST_HINT = (
<>
Images, audio, webm video.{" "}
<a
href="https://github.com/ParachuteComputer/parachute-vault/issues/127"
target="_blank"
rel="noreferrer"
className="underline"
>
PDF + mp4 coming
</a>
</>
);

function AttachmentsSection({
attachments,
uploads,
onPickFiles,
onCancel,
onDismiss,
}: {
attachments: NoteAttachment[];
uploads: ReturnType<typeof useAttachmentUploader>["uploads"];
onPickFiles: (files: File[]) => void;
onCancel: (id: string) => void;
onDismiss: (id: string) => void;
}) {
return (
<section className="mt-6 border-t border-border pt-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-serif text-lg">Attachments</h2>
<AttachmentPicker onPickFiles={onPickFiles} />
</div>
<p className="mb-3 text-xs text-fg-dim">
Drop or paste files into the editor. Max 100 MB each. {ALLOWLIST_HINT}.
</p>
<AttachmentUploadList uploads={uploads} onCancel={onCancel} onDismiss={onDismiss} />
{attachments.length > 0 ? (
<ul className="mt-3 space-y-1 text-sm">
{attachments.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-2 rounded border border-border bg-card/50 px-3 py-1.5 font-mono text-xs"
>
<span className="truncate" title={a.path ?? a.id}>
{a.filename ?? a.path ?? a.id}
</span>
{a.mimeType ? <span className="shrink-0 text-fg-dim">{a.mimeType}</span> : null}
</li>
))}
</ul>
) : null}
</section>
);
}

function ConflictBanner({
conflict,
onReload,
Expand Down
Loading