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
177 changes: 177 additions & 0 deletions src/getContextMenuHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TFile } from "obsidian";
import { get } from "svelte/store";
import getContextMenuHandler from "./getContextMenuHandler";
import { VIEW_TYPE } from "./constants";
import { currentEpisode, viewState } from "./store";
import { ViewState } from "./types/ViewState";

// `tsc` resolves `obsidian` to the real typings while Vitest aliases it to
// tests/mocks/obsidian.ts. Build a TFile (so `file instanceof TFile` holds in
// the handler) carrying the fields the handler reads off an audio file.
function audioFile(path: string): TFile {
const file = new TFile();
Object.assign(file as unknown as Record<string, unknown>, {
path,
extension: path.split(".").pop(),
basename: path.split("/").pop()?.replace(/\.[^.]+$/, ""),
stat: { ctime: 0, mtime: 0, size: 1024 },
});
return file;
}

type CapturedItem = {
title: string;
icon: string;
onClick: (() => Promise<void> | void) | null;
};

// Minimal Menu stand-in: records the item the handler adds and exposes its
// click callback so the test can invoke "Play with PodNotes".
function fakeMenu() {
const items: CapturedItem[] = [];
const menu = {
items,
addItem(cb: (item: unknown) => void) {
const captured: CapturedItem = { title: "", icon: "", onClick: null };
const item = {
setIcon(icon: string) {
captured.icon = icon;
return item;
},
setTitle(title: string) {
captured.title = title;
return item;
},
setSection() {
return item;
},
onClick(fn: () => Promise<void> | void) {
captured.onClick = fn;
return item;
},
};
cb(item);
items.push(captured);
return menu;
},
};
return menu;
}

function setupWorkspace(openLeaves: unknown[]) {
const newLeaf = { setViewState: vi.fn().mockResolvedValue(undefined) };
const workspace = {
_fileMenuHandler: null as
| ((menu: unknown, file: unknown, source: string) => void)
| null,
on(event: string, cb: (menu: unknown, file: unknown, source: string) => void) {
if (event === "file-menu") workspace._fileMenuHandler = cb;
return { event } as unknown;
},
getLeavesOfType: vi.fn(() => openLeaves),
getRightLeaf: vi.fn(() => newLeaf),
revealLeaf: vi.fn(),
};
return { workspace, newLeaf };
}

function makeApp(workspace: unknown, file: TFile) {
const vault = {
getAbstractFileByPath: vi.fn(() => file),
getResourcePath: vi.fn((f: TFile) => `app://resource/${f.path}?1`),
};
// createMediaUrlObjectFromFilePath reads the global `app`.
(globalThis as { app?: unknown }).app = { vault };
return {
workspace,
vault,
fileManager: { generateMarkdownLink: vi.fn(() => `[[${file.path}]]`) },
};
}

async function playFile(app: unknown, workspace: { _fileMenuHandler: unknown }, file: TFile) {
getContextMenuHandler(app as never);
const menu = fakeMenu();
(
workspace._fileMenuHandler as (
menu: unknown,
file: unknown,
source: string,
) => void
)(menu, file, "file-explorer-context-menu");
const play = menu.items.find((i) => i.title === "Play with PodNotes");
expect(play).toBeTruthy();
await play?.onClick?.();
return menu;
}

afterEach(() => {
(globalThis as { app?: unknown }).app = undefined;
vi.restoreAllMocks();
});

beforeEach(() => {
currentEpisode.set(undefined as never);
viewState.set(ViewState.PodcastGrid);
});

describe("getContextMenuHandler — Play with PodNotes", () => {
it("offers the item for audio files and sets it as the current episode", async () => {
const file = audioFile("Audio/tone.mp3");
const { workspace } = setupWorkspace([{}]);
const app = makeApp(workspace, file);

const menu = await playFile(app, workspace, file);

const play = menu.items.find((i) => i.title === "Play with PodNotes");
expect(play?.icon).toBe("play");
expect(get(currentEpisode)).toMatchObject({
title: "tone",
podcastName: "local file",
filePath: "Audio/tone.mp3",
streamUrl: "app://resource/Audio/tone.mp3?1",
});
expect(get(viewState)).toBe(ViewState.Player);
});

it("does not offer the item for non-audio files", () => {
const { workspace } = setupWorkspace([{}]);
const file = audioFile("Notes/page.md");
const app = makeApp(workspace, file);

getContextMenuHandler(app as never);
const menu = fakeMenu();
workspace._fileMenuHandler?.(menu, file, "file-explorer-context-menu");

expect(menu.items.find((i) => i.title === "Play with PodNotes")).toBeUndefined();
});

it("reveals the existing player leaf instead of opening a new one (issue #84)", async () => {
const existingLeaf = { id: "existing" };
const { workspace, newLeaf } = setupWorkspace([existingLeaf]);
const file = audioFile("Audio/already-open.mp3");
const app = makeApp(workspace, file);

await playFile(app, workspace, file);

expect(workspace.getRightLeaf).not.toHaveBeenCalled();
expect(newLeaf.setViewState).not.toHaveBeenCalled();
expect(workspace.revealLeaf).toHaveBeenCalledWith(existingLeaf);
});

it("opens and reveals the player when no leaf is present (issue #84)", async () => {
const { workspace, newLeaf } = setupWorkspace([]);
const file = audioFile("Audio/closed-pane.mp3");
const app = makeApp(workspace, file);

await playFile(app, workspace, file);

expect(workspace.getRightLeaf).toHaveBeenCalledWith(false);
expect(newLeaf.setViewState).toHaveBeenCalledWith({
type: VIEW_TYPE,
active: true,
});
expect(workspace.revealLeaf).toHaveBeenCalledWith(newLeaf);
});
});
34 changes: 33 additions & 1 deletion src/getContextMenuHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { App, EventRef, Menu, TAbstractFile } from "obsidian";
import type { App, EventRef, Menu, TAbstractFile, WorkspaceLeaf } from "obsidian";
import { TFile } from "obsidian";
import { get } from "svelte/store";
import { VIEW_TYPE } from "./constants";
import {
downloadedEpisodes,
playedEpisodes,
Expand Down Expand Up @@ -74,8 +75,39 @@ export default function getContextMenuHandler(app: App): EventRef {

currentEpisode.set(localEpisode);
viewState.set(ViewState.Player);

// Setting the stores above only updates an already-mounted
// PodNotes view. When the view is closed (or hidden in a
// collapsed sidebar) nothing reacts, so "Play with PodNotes"
// silently did nothing — the file played to no visible player
// (issue #84). Open the view if needed and reveal it so the
// player surfaces with the just-selected episode.
await revealPodcastView(app);
})
);
}
);
}

/**
* Ensures the PodNotes player view is open and brought into view.
*
* Mirrors the "Show PodNotes" command and onLayoutReady: reuse an existing leaf
* when present, otherwise open the view in the right sidebar. revealLeaf also
* surfaces a leaf that exists but is hidden inside a collapsed sidebar.
*/
async function revealPodcastView(app: App): Promise<void> {
const { workspace } = app;

let leaf: WorkspaceLeaf | null =
workspace.getLeavesOfType(VIEW_TYPE)[0] ?? null;

if (!leaf) {
leaf = workspace.getRightLeaf(false);
if (!leaf) return;

await leaf.setViewState({ type: VIEW_TYPE, active: true });
}

await workspace.revealLeaf(leaf);
}
Loading