Skip to content
Open
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
130 changes: 130 additions & 0 deletions apps/desktop/native-backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,7 @@ impl NativeBackend {
}
"save_vault_file" => self.save_vault_file(args),
"save_vault_binary_file" => self.save_vault_binary_file(args),
"copy_external_file_to_vault" => self.copy_external_file_to_vault(args),
"read_note" => {
let state = self.state(&args)?;
let note_id = required_string(&args, &["noteId", "note_id"])?;
Expand Down Expand Up @@ -1461,6 +1462,77 @@ impl NativeBackend {
Ok(json!(detail))
}

fn copy_external_file_to_vault(&mut self, args: Value) -> Result<Value, String> {
let source_path = required_string(&args, &["sourcePath", "source_path"])?;
let target_folder =
optional_string(&args, &["targetFolder", "target_folder"]).unwrap_or_default();
let (vault_path, state) = self.state_mut(&args)?;

let source = std::path::PathBuf::from(&source_path);
if !source.is_file() {
return Err(format!("Source file not found: {source_path}"));
}

let file_name = source
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| "Could not determine file name from source path".to_string())?
.to_string();

let target = state
.vault
.prepare_binary_file_target(&target_folder, &file_name)
.map_err(|error| error.to_string())?;

state.write_tracker.track_any(target.clone());
fs::copy(&source, &target).map_err(|error| error.to_string())?;

let entry = state
.vault
.read_vault_entry_from_path(&target)
.map_err(|error| error.to_string())?;

let detail = SavedBinaryFileDetail {
path: entry.path.clone(),
relative_path: entry.relative_path.clone(),
file_name: entry.file_name.clone(),
mime_type: entry.mime_type.clone(),
};
Self::refresh_vault_state(state)?;
let change = if entry.kind == "note" {
let note = state
.vault
.read_note_from_path(&target)
.map_err(|error| error.to_string())?;
let revision = advance_revision(&mut state.note_revisions, &note.id.0, None).max(1);
note_change_from_document(
&vault_path,
&note,
detail.relative_path.clone(),
None,
revision,
state.graph_revision.max(1),
)
} else {
let revision =
advance_revision(&mut state.file_revisions, &entry.relative_path, None).max(1);
build_vault_note_change(
&vault_path,
"upsert",
None,
None,
Some(entry),
Some(detail.relative_path.clone()),
None,
revision,
None,
state.graph_revision.max(1),
)
};
self.emit_vault_change(change);
Ok(json!(detail))
}

fn save_note(&mut self, args: Value) -> Result<Value, String> {
let note_id = required_string(&args, &["noteId", "note_id"])?;
let content = required_string_allow_empty(&args, &["content"])?;
Expand Down Expand Up @@ -3311,6 +3383,64 @@ mod tests {
);
}

#[test]
fn external_markdown_copy_emits_note_change() {
let (event_tx, event_rx) = mpsc::channel::<RpcOutput>();
let backend = Arc::new(Mutex::new(NativeBackend::new(event_tx)));
let vault_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("Imported.md");
fs::write(&source_path, "# Imported\n\n[[Existing]] #tag-one\n").unwrap();

let vault_path = vault_dir.path().to_string_lossy().to_string();
invoke(&backend, "start_open_vault", json!({ "path": vault_path })).unwrap();

let detail = invoke(
&backend,
"copy_external_file_to_vault",
json!({
"vaultPath": vault_path,
"sourcePath": source_path.to_string_lossy().to_string(),
"targetFolder": "Inbox",
}),
)
.unwrap();
assert_eq!(
detail.get("relative_path").and_then(Value::as_str),
Some("Inbox/Imported.md")
);

let change = recv_vault_change(&event_rx);
assert_eq!(change.get("kind").and_then(Value::as_str), Some("upsert"));
assert_eq!(
change.get("note_id").and_then(Value::as_str),
Some("Inbox/Imported")
);
assert_eq!(
change.get("relative_path").and_then(Value::as_str),
Some("Inbox/Imported.md")
);
assert_eq!(change.get("entry"), Some(&Value::Null));
assert_eq!(
change
.get("note")
.and_then(|note| note.get("id"))
.and_then(Value::as_str),
Some("Inbox/Imported")
);
assert_eq!(
change.get("content_hash").and_then(Value::as_str),
Some(content_hash_bytes(b"# Imported\n\n[[Existing]] #tag-one\n").as_str())
);

let notes = invoke(&backend, "list_notes", json!({ "vaultPath": vault_path })).unwrap();
assert!(notes
.as_array()
.unwrap()
.iter()
.any(|note| note.get("id").and_then(Value::as_str) == Some("Inbox/Imported")));
}

#[test]
fn ai_review_file_ops_accept_absolute_paths_inside_vault() {
let (event_tx, _event_rx) = mpsc::channel::<RpcOutput>();
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-electron/main/nativeBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const SUPPORTED_COMMANDS = new Set([
"read_vault_file",
"save_vault_file",
"save_vault_binary_file",
"copy_external_file_to_vault",
"read_note",
"save_note",
"create_note",
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/features/ai/dragEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,20 @@ export function emitFileTreeAttachToNewChat(detail: FileTreeNoteDragDetail) {
),
);
}

export const EXTERNAL_FILE_TREE_DRAG_EVENT =
"neverwrite:external-file-tree-drag";

export interface ExternalFileTreeDragDetail {
phase: "over" | "cancel";
folderPath: string | null;
}

export function emitExternalFileTreeDrag(detail: ExternalFileTreeDragDetail) {
window.dispatchEvent(
new CustomEvent<ExternalFileTreeDragDetail>(
EXTERNAL_FILE_TREE_DRAG_EVENT,
{ detail },
),
);
}
113 changes: 112 additions & 1 deletion apps/desktop/src/features/editor/MultiPaneWorkspace.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, screen } from "@testing-library/react";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import {
flushPromises,
getMockCurrentWebview,
Expand Down Expand Up @@ -204,6 +204,10 @@ describe("MultiPaneWorkspace", () => {
y: 40,
}),
});
Object.defineProperty(document, "elementsFromPoint", {
configurable: true,
value: vi.fn(() => []),
});
});

it("focuses the clicked pane", () => {
Expand Down Expand Up @@ -324,6 +328,113 @@ describe("MultiPaneWorkspace", () => {
expect(useEditorStore.getState().focusedPaneId).toBe("secondary");
});

it("copies external files into the hovered file-tree folder", async () => {
const originalRefreshStructure =
useVaultStore.getState().refreshStructure;
const refreshStructure = vi.fn(async () => {});
useVaultStore.setState({ refreshStructure });
mockInvoke().mockResolvedValue({
relative_path: "Projects/draft.pdf",
path: "/vaults/main/Projects/draft.pdf",
file_name: "draft.pdf",
mime_type: "application/pdf",
});
const folder = document.createElement("div");
folder.setAttribute("data-folder-path", "Projects");
vi.mocked(document.elementsFromPoint).mockReturnValue([folder]);

renderComponent(<MultiPaneWorkspace />);
await flushPromises();

const dragDropListener = onDragDropEventMock.mock.calls.at(-1)?.[0] as
| ((event: {
payload: {
type: "drop";
position: { x: number; y: number };
paths: string[];
};
}) => void)
| undefined;
expect(dragDropListener).toBeTypeOf("function");

await act(async () => {
dragDropListener?.({
payload: {
type: "drop",
position: { x: 24, y: 48 },
paths: ["/Users/jfg/Desktop/draft.pdf"],
},
});
await flushPromises();
});

expect(mockInvoke()).toHaveBeenCalledWith("copy_external_file_to_vault", {
sourcePath: "/Users/jfg/Desktop/draft.pdf",
targetFolder: "Projects",
vaultPath: "/vaults/main",
});
await waitFor(() => {
expect(refreshStructure).toHaveBeenCalledTimes(1);
});
expect(useEditorStore.getState().panes.flatMap((pane) => pane.tabs)).toEqual(
[],
);
useVaultStore.setState({ refreshStructure: originalRefreshStructure });
});

it("copies external files into the parent folder when hovering a file row", async () => {
const originalRefreshStructure =
useVaultStore.getState().refreshStructure;
const refreshStructure = vi.fn(async () => {});
useVaultStore.setState({ refreshStructure });
mockInvoke().mockResolvedValue({
relative_path: "Projects/assets/draft.pdf",
path: "/vaults/main/Projects/assets/draft.pdf",
file_name: "draft.pdf",
mime_type: "application/pdf",
});
const fileRow = document.createElement("div");
fileRow.setAttribute("data-folder-path", "Projects/assets");
const fileLabel = document.createElement("span");
fileRow.append(fileLabel);
vi.mocked(document.elementsFromPoint).mockReturnValue([fileLabel]);

renderComponent(<MultiPaneWorkspace />);
await flushPromises();

const dragDropListener = onDragDropEventMock.mock.calls.at(-1)?.[0] as
| ((event: {
payload: {
type: "drop";
position: { x: number; y: number };
paths: string[];
};
}) => void)
| undefined;
expect(dragDropListener).toBeTypeOf("function");

await act(async () => {
dragDropListener?.({
payload: {
type: "drop",
position: { x: 24, y: 48 },
paths: ["/Users/jfg/Desktop/draft.pdf"],
},
});
await flushPromises();
});

expect(mockInvoke()).toHaveBeenCalledWith("copy_external_file_to_vault", {
sourcePath: "/Users/jfg/Desktop/draft.pdf",
targetFolder: "Projects/assets",
vaultPath: "/vaults/main",
});
await waitFor(() => {
expect(refreshStructure).toHaveBeenCalledTimes(1);
});
useVaultStore.setState({ refreshStructure: originalRefreshStructure });
});

it("does not open a pane tab when the drop lands over the composer zone", async () => {
setVaultEntries([
{
Expand Down
Loading
Loading