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
4 changes: 2 additions & 2 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
ringdrop = "0.14.1"
ringdrop = "0.16.0"

83 changes: 83 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ pub struct BlobRow {
pub rings: Vec<String>,
/// `rdrop://…` share ticket.
pub ticket: String,
/// Human-readable kind string, e.g. `"file"` or `"dir, 3 files"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
/// Number of files in a directory blob (`HashSeq`), if known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_count: Option<u64>,
/// Total size in bytes, if known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
}

/// Result returned after a successful import.
Expand Down Expand Up @@ -86,6 +95,15 @@ pub struct RemoteBlobRow {
pub name: String,
/// `rdrop://…` share ticket.
pub ticket: String,
/// Human-readable kind string, e.g. `"file"` or `"dir, 3 files"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
/// Number of files in a directory blob, if known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_count: Option<u64>,
/// Total size in bytes, if known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
}

/// GUI and daemon version numbers, baked in at compile time.
Expand Down Expand Up @@ -403,6 +421,24 @@ pub async fn receive(
serde_json::json!({ "done": done, "total": total }),
);
}
EventKind::FileProgress {
file_index,
file_total,
ref file_name,
done,
total,
} => {
let _ = app.emit(
&format!("transfer_progress/{hash}"),
serde_json::json!({
"done": done,
"total": total,
"file_index": file_index,
"file_total": file_total,
"file_name": file_name,
}),
);
}
// DaemonClient::send delivers Error through the callback and
// still returns Ok(()), so we must capture it manually.
EventKind::Error { message } => recv_error = Some(message),
Expand Down Expand Up @@ -436,6 +472,36 @@ mod tests {
"hash": "abc", "name": "f.txt", "rings": ["x"], "ticket": "rdrop://t"
}))]);
assert_eq!(rows[0].rings, vec!["x"]);
assert_eq!(rows[0].kind, None);
assert_eq!(rows[0].size_bytes, None);
}

#[test]
fn collect_records_deserializes_blob_rows_with_rich_fields() {
let rows: Vec<BlobRow> = collect_records(vec![rec(json!({
"hash": "abc",
"name": "photos",
"rings": [],
"ticket": "rdrop://t",
"kind": "dir, 3 files",
"file_count": 3,
"size_bytes": 1048576,
}))]);
assert_eq!(rows[0].kind.as_deref(), Some("dir, 3 files"));
assert_eq!(rows[0].file_count, Some(3));
assert_eq!(rows[0].size_bytes, Some(1048576));
}

#[test]
fn collect_records_ignores_file_progress_events() {
let rows: Vec<BlobRow> = collect_records(vec![EventKind::FileProgress {
file_index: 1,
file_total: 3,
file_name: "readme.txt".into(),
done: 512,
total: 1024,
}]);
assert!(rows.is_empty());
}

#[test]
Expand Down Expand Up @@ -500,6 +566,23 @@ mod tests {
"hash": "abc", "name": "video.mp4", "ticket": "rdrop://abc"
}))]);
assert_eq!(rows[0].name, "video.mp4");
assert_eq!(rows[0].kind, None);
assert_eq!(rows[0].size_bytes, None);
}

#[test]
fn collect_records_deserializes_remote_blob_rows_with_rich_fields() {
let rows: Vec<RemoteBlobRow> = collect_records(vec![rec(json!({
"hash": "abc",
"name": "photos",
"ticket": "rdrop://abc",
"kind": "dir, 5 files",
"file_count": 5,
"size_bytes": 2097152,
}))]);
assert_eq!(rows[0].kind.as_deref(), Some("dir, 5 files"));
assert_eq!(rows[0].file_count, Some(5));
assert_eq!(rows[0].size_bytes, Some(2097152));
}

#[test]
Expand Down
12 changes: 11 additions & 1 deletion src/lib/BlobTable.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import type { BlobRow, RingRow } from "./types";
import ConfirmButton from "./ConfirmButton.svelte";
import { formatBytes } from "./utils";

interface Props {
rows: BlobRow[];
Expand Down Expand Up @@ -47,7 +48,16 @@
{/if}
{#each rows as row (row.hash)}
<tr class="group border-b border-neutral-900 transition-colors hover:bg-neutral-900/60">
<td class="overflow-hidden break-words py-2.5 pr-4 text-neutral-100">{row.name}</td>
<td class="overflow-hidden break-words py-2.5 pr-4 text-neutral-100">
{row.name}
{#if row.kind || row.size_bytes != null}
<span class="block text-xs text-neutral-600">
{[row.kind, row.size_bytes != null ? formatBytes(row.size_bytes) : null]
.filter(Boolean)
.join(" · ")}
</span>
{/if}
</td>
<td class="max-w-0 py-2.5 pr-4">
<span class="block truncate font-mono text-xs text-neutral-500" title={row.hash}>{row.hash}</span>
</td>
Expand Down
24 changes: 24 additions & 0 deletions src/lib/BlobTable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,28 @@ describe("BlobTable", () => {
await fireEvent.change(select, { target: { value: "work" } });
expect(onAttach).toHaveBeenCalledWith(rows[0].hash, rings[1]);
});

it("shows kind sub-line when kind is provided", () => {
const enrichedRows = [{ ...rows[0], kind: "file" }];
const { getByText } = render(BlobTable, { props: { ...defaultProps, rows: enrichedRows } });
expect(getByText("file")).toBeTruthy();
});

it("shows formatted size sub-line when size_bytes is provided", () => {
const enrichedRows = [{ ...rows[0], size_bytes: 1048576 }];
const { getByText } = render(BlobTable, { props: { ...defaultProps, rows: enrichedRows } });
expect(getByText("1.0 MB")).toBeTruthy();
});

it("shows kind and size joined by · when both are provided", () => {
const enrichedRows = [{ ...rows[0], kind: "dir, 3 files", size_bytes: 3145728 }];
const { getByText } = render(BlobTable, { props: { ...defaultProps, rows: enrichedRows } });
expect(getByText("dir, 3 files · 3.0 MB")).toBeTruthy();
});

it("shows no kind/size sub-line when neither field is present", () => {
const { queryByText } = render(BlobTable, { props: defaultProps });
// Default rows have no kind/size_bytes — no · separator should appear.
expect(queryByText(/·/)).toBeNull();
});
});
34 changes: 31 additions & 3 deletions src/lib/ReceivePanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,22 @@
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { formatBytes } from "./utils";

interface ProgressPayload {
done: number;
total: number;
file_index?: number;
file_total?: number;
file_name?: string;
}

let ticket = $state("");
let dest = $state("");
let downloading = $state(false);
let done = $state(0);
let total = $state(0);
let fileName: string | null = $state(null);
let fileIndex: number | null = $state(null);
let fileTotal: number | null = $state(null);
let error: string | null = $state(null);
let success = $state(false);

Expand All @@ -25,19 +36,28 @@
downloading = true;
done = 0;
total = 0;
fileName = null;
fileIndex = null;
fileTotal = null;
error = null;
success = false;

const unlisten: UnlistenFn = await listen<{ done: number; total: number }>(
"transfer_progress",
// A random channel ID namespaces the progress events for this download.
const channelId = crypto.randomUUID();

const unlisten: UnlistenFn = await listen<ProgressPayload>(
`transfer_progress/${channelId}`,
(ev) => {
done = ev.payload.done;
total = ev.payload.total;
fileName = ev.payload.file_name ?? null;
fileIndex = ev.payload.file_index ?? null;
fileTotal = ev.payload.file_total ?? null;
},
);

try {
await invoke("receive", { ticket: ticket.trim(), dest });
await invoke("receive", { ticket: ticket.trim(), dest, hash: channelId });
success = true;
} catch (e) {
error = String(e);
Expand Down Expand Up @@ -108,6 +128,14 @@
Connecting…
{/if}
</p>
{#if fileName}
<p class="text-xs text-neutral-600">
{#if fileIndex != null && fileTotal != null}
File {fileIndex}/{fileTotal}:
{/if}
{fileName}
</p>
{/if}
</div>
{/if}

Expand Down
49 changes: 47 additions & 2 deletions src/lib/ReceivePanel.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/svelte";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, fireEvent } from "@testing-library/svelte";
import ReceivePanel from "./ReceivePanel.svelte";

vi.mock("@tauri-apps/api/core", () => ({
Expand All @@ -15,8 +15,10 @@ vi.mock("@tauri-apps/plugin-dialog", () => ({
}));

const { invoke } = await import("@tauri-apps/api/core");
const { listen } = await import("@tauri-apps/api/event");
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
const mockInvoke = vi.mocked(invoke);
const mockListen = vi.mocked(listen);
const mockOpen = vi.mocked(openDialog);


Expand Down Expand Up @@ -74,4 +76,47 @@ describe("ReceivePanel", () => {
await fireEvent.click(await findByText("Download"));
expect(await findByText(/connection refused/)).toBeTruthy();
});

it("shows file name when a FileProgress event arrives", async () => {
// Capture the progress callback so we can fire it manually.
let progressCb: ((ev: { payload: Record<string, unknown> }) => void) | null = null;
mockListen.mockImplementationOnce(async (_channel, cb) => {
progressCb = cb as typeof progressCb;
return () => {};
});
// Keep the download pending so we can inspect the in-flight state.
let finishDownload!: () => void;
mockInvoke.mockImplementationOnce(() => new Promise<void>(res => { finishDownload = res; }));

const { getByPlaceholderText, getByText, findByText } = render(ReceivePanel);
await fireEvent.input(getByPlaceholderText("rdrop://…"), { target: { value: "rdrop://abc" } });
await fireEvent.click(getByText("Browse"));
await fireEvent.click(await findByText("Download"));

// Flush microtasks so `await listen(...)` has resolved and the callback is captured.
await new Promise(r => setTimeout(r, 0));
expect(progressCb).not.toBeNull();

progressCb!({ payload: { done: 512, total: 1024, file_index: 2, file_total: 5, file_name: "notes.txt" } });

expect(await findByText(/notes\.txt/)).toBeTruthy();
expect(await findByText(/2\/5/)).toBeTruthy();

finishDownload();
});

it("does not show file name line when no FileProgress has arrived", async () => {
let finishDownload!: () => void;
mockInvoke.mockImplementationOnce(() => new Promise<void>(res => { finishDownload = res; }));

const { getByPlaceholderText, getByText, findByText, queryByText } = render(ReceivePanel);
await fireEvent.input(getByPlaceholderText("rdrop://…"), { target: { value: "rdrop://abc" } });
await fireEvent.click(getByText("Browse"));
await fireEvent.click(await findByText("Download"));

await new Promise(r => setTimeout(r, 0));
expect(queryByText(/File \d+\/\d+/)).toBeNull();

finishDownload();
});
});
27 changes: 25 additions & 2 deletions src/lib/RemotePanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
import type { RemoteBlobRow } from "./types";
import { formatBytes } from "./utils";

interface Progress { done: number; total: number }
interface Progress {
done: number;
total: number;
file_index?: number;
file_total?: number;
file_name?: string;
}

let peerId = $state("");
let blobs: RemoteBlobRow[] = $state([]);
Expand Down Expand Up @@ -107,7 +113,16 @@
<tbody>
{#each blobs as row (row.hash)}
<tr class="border-b border-neutral-900 hover:bg-neutral-900/50">
<td class="overflow-hidden break-words py-2.5 pr-4 text-neutral-100">{row.name}</td>
<td class="overflow-hidden break-words py-2.5 pr-4 text-neutral-100">
{row.name}
{#if row.kind || row.size_bytes != null}
<span class="block text-xs text-neutral-600">
{[row.kind, row.size_bytes != null ? formatBytes(row.size_bytes) : null]
.filter(Boolean)
.join(" · ")}
</span>
{/if}
</td>
<td class="max-w-0 py-2.5 pr-4">
<span class="block truncate font-mono text-xs text-neutral-500" title={row.hash}>{row.hash}</span>
</td>
Expand All @@ -133,6 +148,14 @@
? `${formatBytes(progresses[row.hash].done)} / ${formatBytes(progresses[row.hash].total)}`
: "Connecting…"}
</span>
{#if progresses[row.hash]?.file_name}
<span class="block truncate text-xs text-neutral-700" title={progresses[row.hash].file_name}>
{#if progresses[row.hash].file_index != null && progresses[row.hash].file_total != null}
{progresses[row.hash].file_index}/{progresses[row.hash].file_total}
{/if}
{progresses[row.hash].file_name}
</span>
{/if}
</div>
{/if}
</div>
Expand Down
Loading
Loading