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
8 changes: 5 additions & 3 deletions docs/docs/commands.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
## Show PodNotes
This command is only accessible if there doesn't exist a pane with PodNotes already.
Opens the PodNotes pane and brings it into focus.

Activating it will spawn a new pane with PodNotes in the right sidebar.
If the pane already exists but is hidden — for example in a collapsed sidebar or out of view because the right sidebar has too many icons — this command reveals it. If it does not exist yet, the command creates it in the right sidebar. You can run it from the command palette or bind it to a hotkey.

If you are having issues with PodNotes not being shown, feel free to create an [issue](https://github.com/chhoumann/PodNotes/issues/new). However, do make sure to check that the icon isn't just out of view by scrolling on the right sidebar.
PodNotes also adds a **podcast icon to the left ribbon** as a reliable way to reopen the pane. On mobile it appears in the ribbon menu, and you can hide it via Obsidian's *Manage ribbon actions* if you prefer to use the command instead.

If you are having issues with PodNotes not being shown, feel free to create an [issue](https://github.com/chhoumann/PodNotes/issues/new).

## Play Podcast
This will start playback if the current episode is paused.
Expand Down
166 changes: 166 additions & 0 deletions src/main.activateView.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import PodNotes from "./main";
import { VIEW_TYPE } from "./constants";

// Regression coverage for #55: "Show PodNotes" / the ribbon icon must reliably
// surface the view. The bug was that the command was gated on the leaf NOT
// existing and never revealed it, so an already-open-but-hidden view (collapsed
// or overflowing sidebar) could not be brought back. activateView reuses the
// existing leaf when present and always reveals it.

function makeLeaf() {
return {
setViewState: vi.fn().mockResolvedValue(undefined),
};
}

function setupPlugin({
existingLeaves = [] as ReturnType<typeof makeLeaf>[],
rightLeaf = makeLeaf() as ReturnType<typeof makeLeaf> | null,
} = {}) {
const workspace = {
getLeavesOfType: vi.fn().mockReturnValue(existingLeaves),
getRightLeaf: vi.fn().mockReturnValue(rightLeaf),
revealLeaf: vi.fn().mockResolvedValue(undefined),
};

// Build a bare instance so we exercise activateView without running the full
// onload() side effects (store wiring, command registration, etc.).
const plugin = Object.create(PodNotes.prototype) as PodNotes;
(plugin as unknown as { app: { workspace: typeof workspace } }).app = {
workspace,
};

return { plugin, workspace, rightLeaf };
}

describe("PodNotes.activateView", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("reuses an existing leaf and reveals it without creating a new one", async () => {
const existing = makeLeaf();
const { plugin, workspace } = setupPlugin({ existingLeaves: [existing] });

await plugin.activateView();

expect(workspace.getRightLeaf).not.toHaveBeenCalled();
expect(existing.setViewState).not.toHaveBeenCalled();
expect(workspace.revealLeaf).toHaveBeenCalledTimes(1);
expect(workspace.revealLeaf).toHaveBeenCalledWith(existing);
});

it("creates a right-sidebar leaf when none exists, then reveals it", async () => {
const { plugin, workspace, rightLeaf } = setupPlugin();

await plugin.activateView();

expect(workspace.getRightLeaf).toHaveBeenCalledWith(false);
expect(rightLeaf?.setViewState).toHaveBeenCalledWith({
type: VIEW_TYPE,
active: true,
});
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
});

it("does not throw or reveal when no right leaf is available", async () => {
const { plugin, workspace } = setupPlugin({ rightLeaf: null });

await expect(plugin.activateView()).resolves.toBeUndefined();
expect(workspace.revealLeaf).not.toHaveBeenCalled();
});
});

// Locks the actual #55 wiring (not just activateView's internals): the
// "Show PodNotes" command must stay always-available (a plain callback, never
// a leaf-gated checkCallback) and the ribbon icon must route to activateView.
// A refactor that reintroduced the old checkCallback gate or unwired the ribbon
// would reproduce the bug while activateView's own unit tests stayed green.
describe("PodNotes onload wiring (#55)", () => {
// onload() wires real module-level stores to controllers; unload them after
// each test so leaked subscriptions don't fire into a disposed plugin.
const loaded: PodNotes[] = [];

beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
for (const p of loaded.splice(0)) {
p.onunload();
}
vi.restoreAllMocks();
});

async function loadPlugin() {
const activateSpy = vi
.spyOn(PodNotes.prototype, "activateView")
.mockResolvedValue(undefined);

const commands: Array<Record<string, unknown>> = [];
const ribbonCalls: Array<{
icon: string;
title: string;
handler: (evt: unknown) => unknown;
}> = [];

const plugin = Object.create(PodNotes.prototype) as PodNotes;
Object.assign(plugin, {
loadData: vi.fn().mockResolvedValue({}),
saveData: vi.fn().mockResolvedValue(undefined),
addCommand: vi.fn((cmd: Record<string, unknown>) => {
commands.push(cmd);
return cmd;
}),
addRibbonIcon: vi.fn(
(icon: string, title: string, handler: (evt: unknown) => unknown) => {
ribbonCalls.push({ icon, title, handler });
return document.createElement("div");
},
),
addSettingTab: vi.fn(),
registerView: vi.fn(),
registerObsidianProtocolHandler: vi.fn(),
registerEvent: vi.fn(),
app: {
workspace: {
onLayoutReady: vi.fn(),
on: vi.fn(() => ({})),
getLeavesOfType: vi.fn(() => []),
getRightLeaf: vi.fn(() => null),
revealLeaf: vi.fn(),
detachLeavesOfType: vi.fn(),
},
},
});

await plugin.onload();
loaded.push(plugin);

return { commands, ribbonCalls, activateSpy };
}

it("registers Show PodNotes as an always-available callback, not a leaf-gated checkCallback", async () => {
const { commands } = await loadPlugin();

const showCmd = commands.find((c) => c.id === "podnotes-show-leaf");
expect(showCmd).toBeDefined();
expect(typeof showCmd?.callback).toBe("function");
expect(showCmd?.checkCallback).toBeUndefined();
});

it("Show PodNotes command and ribbon icon both route to activateView", async () => {
const { commands, ribbonCalls, activateSpy } = await loadPlugin();

const showCmd = commands.find((c) => c.id === "podnotes-show-leaf");
(showCmd?.callback as () => void)();
expect(activateSpy).toHaveBeenCalledTimes(1);

const ribbon = ribbonCalls.find((r) => r.title === "Show PodNotes");
expect(ribbon).toBeDefined();
expect(ribbon?.icon).toBe("podcast");
ribbon?.handler(new MouseEvent("click"));
expect(activateSpy).toHaveBeenCalledTimes(2);
});
});
43 changes: 35 additions & 8 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,13 @@ export default class PodNotes extends Plugin implements IPodNotes {
id: "podnotes-show-leaf",
name: "Show PodNotes",
icon: "podcast" as IconType,
checkCallback: (checking: boolean) => {
if (checking) {
return !this.app.workspace.getLeavesOfType(VIEW_TYPE).length;
}

this.app.workspace.getRightLeaf(false)?.setViewState({
type: VIEW_TYPE,
});
// Always available, and always reveals the view. The previous
// checkCallback hid this command whenever a leaf already existed, so
// once the view was open-but-hidden (collapsed sidebar, sidebar
// overflow, dragged out of sight) there was no way to bring it back
// (#55). activateView reuses the existing leaf and reveals it.
callback: () => {
void this.activateView();
},
});

Expand Down Expand Up @@ -377,6 +376,14 @@ export default class PodNotes extends Plugin implements IPodNotes {
return this.view;
});

// Persistent, discoverable entry point in the left ribbon. The right
// sidebar header can overflow and hide the view's tab icon (the original
// report in #55), but the ribbon is always reachable, so users can always
// reopen PodNotes.
this.addRibbonIcon("podcast" as IconType, "Show PodNotes", () => {
void this.activateView();
});

this.app.workspace.onLayoutReady(this.onLayoutReady.bind(this));

this.registerObsidianProtocolHandler("podnotes", (action) =>
Expand Down Expand Up @@ -415,6 +422,26 @@ export default class PodNotes extends Plugin implements IPodNotes {
}
}

// Reveal the PodNotes view, creating its leaf when needed. Reusing an
// existing leaf (instead of gating on its absence) plus revealLeaf is what
// makes "Show PodNotes" and the ribbon icon reliably surface the view even
// when it is already open but hidden in a collapsed/overflowing sidebar (#55).
async activateView(): Promise<void> {
const { workspace } = this.app;

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

if (!leaf) {
leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: VIEW_TYPE, active: true });
}

if (leaf) {
await workspace.revealLeaf(leaf);
}
}

private getTranscriptionService(): TranscriptionService {
if (!this.transcriptionService) {
this.transcriptionService = new TranscriptionService(this);
Expand Down
Loading