Skip to content
Closed
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ Renders an inline swatch beside every color literal in a thread — hex, `rgb()`

Install: `bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/color-swatches --yes`

### Open in Moss

Makes local Markdown links in bb open directly in Moss, with bb's viewer kept as the fallback.

![A Markdown file link from bb open in Moss](plugins/open-in-moss/docs/screenshot.png)

[Source](plugins/open-in-moss) · [README](plugins/open-in-moss/README.md)

Install: `bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/open-in-moss --yes`

### Timeline Comments

Attaches durable discussion threads to selected timeline text. Users and agents can reply, edit, resolve or reopen comments, review them together, and add open feedback to the composer for follow-up.
Expand Down
29 changes: 29 additions & 0 deletions package-lock.json

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

26 changes: 26 additions & 0 deletions plugins/open-in-moss/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Open in Moss

Makes local Markdown links in bb open directly in Moss.

![A Markdown file link from bb open in Moss](docs/screenshot.png)

## Install

```sh
bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/open-in-moss --yes
```

## Use

Click any local `.md` or `.markdown` link in bb. It opens in Moss instead of
bb's file viewer.

Right-click still uses bb's normal menu. If Moss or the local file is
unavailable, bb opens its own viewer and shows a notice.

## Develop

```sh
npm install
npm run check --workspace=bb-plugin-open-in-moss
```
134 changes: 134 additions & 0 deletions plugins/open-in-moss/app.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
loadPluginApp,
mountPluginContentScripts,
type MountedPluginContentScripts,
} from "@get-bb/plugin-sdk/testing/app";
import { toast } from "sonner";

vi.mock("sonner", () => ({
toast: { error: vi.fn() },
}));

const app = await loadPluginApp(() => import("./app"));
let mounted: MountedPluginContentScripts;

function link(href: string): HTMLAnchorElement {
const anchor = document.createElement("a");
anchor.href = href;
const child = document.createElement("span");
child.textContent = "Open file";
anchor.append(child);
document.body.append(anchor);
return anchor;
}

function click(
target: Element,
init: MouseEventInit = {},
): MouseEvent {
const event = new MouseEvent("click", {
bubbles: true,
cancelable: true,
button: 0,
...init,
});
target.dispatchEvent(event);
return event;
}

beforeEach(async () => {
mounted = await mountPluginContentScripts(app, {
pluginId: "open-in-moss",
});
});

afterEach(async () => {
await mounted.lifecycle.dispose();
document.body.replaceChildren();
vi.clearAllMocks();
vi.unstubAllGlobals();
});

describe("Markdown link interception", () => {
it("opens encoded Markdown file links through the plugin route", async () => {
const fetch = vi.fn(async () => ({ ok: true }));
vi.stubGlobal("fetch", fetch);
const anchor = link("file:///Users/brsbl/My%20Notes/spec.md#L12");
const reachedAnchor = vi.fn();
anchor.addEventListener("click", reachedAnchor);

const event = click(anchor.firstElementChild!);

expect(event.defaultPrevented).toBe(true);
expect(reachedAnchor).not.toHaveBeenCalled();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
expect(fetch).toHaveBeenCalledWith(
"/api/v1/plugins/open-in-moss/http/open",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path: "/Users/brsbl/My Notes/spec.md" }),
},
);
expect(toast.error).not.toHaveBeenCalled();
});

it("falls back to the original bb click when Moss cannot open the file", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false })));
const anchor = link("file:///workspace/spec.markdown");
const bbPreview = vi.fn((event: Event) => event.preventDefault());
anchor.addEventListener("click", bbPreview);

click(anchor);

await vi.waitFor(() => expect(bbPreview).toHaveBeenCalledOnce());
expect(toast.error).toHaveBeenCalledWith("Moss couldn’t open this file", {
description: "It was opened in bb instead.",
});
});

it("intercepts modified primary clicks so they cannot open bb's viewer", async () => {
const fetch = vi.fn(async () => ({ ok: true }));
vi.stubGlobal("fetch", fetch);
const anchor = link("file:///workspace/spec.md");
const event = click(anchor, { metaKey: true, shiftKey: true });

expect(event.defaultPrevented).toBe(true);
await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
});

it("leaves non-Markdown, web, and right clicks alone", async () => {
const fetch = vi.fn(async () => ({ ok: true }));
vi.stubGlobal("fetch", fetch);
const cases: Array<[HTMLAnchorElement, MouseEventInit]> = [
[link("file:///workspace/code.ts"), {}],
[link("https://example.com/readme.md"), {}],
[link("file:///workspace/spec.md"), { button: 2 }],
];

for (const [anchor, init] of cases) {
const reachedAnchor = vi.fn();
anchor.addEventListener("click", (event) => {
reachedAnchor();
event.preventDefault();
});
click(anchor, init);
expect(reachedAnchor).toHaveBeenCalledOnce();
}
expect(fetch).not.toHaveBeenCalled();
});

it("removes the interceptor when the plugin is disposed", async () => {
const fetch = vi.fn(async () => ({ ok: true }));
vi.stubGlobal("fetch", fetch);
await mounted.lifecycle.dispose();
const anchor = link("file:///workspace/spec.md");
anchor.addEventListener("click", (event) => event.preventDefault());

click(anchor);

expect(fetch).not.toHaveBeenCalled();
});
});
101 changes: 101 additions & 0 deletions plugins/open-in-moss/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { definePluginApp } from "@get-bb/plugin-sdk/app";
import { toast } from "sonner";

const MARKDOWN_EXTENSION = /\.(?:md|markdown)$/iu;
const fallbackEvents = new WeakSet<Event>();

interface MarkdownFileLink {
anchor: HTMLAnchorElement;
path: string;
}

function markdownFileLinkFromClick(event: MouseEvent): MarkdownFileLink | null {
if (
event.button !== 0 ||
event.defaultPrevented
) {
return null;
}

const anchor = event
.composedPath()
.find((target): target is HTMLAnchorElement =>
target instanceof HTMLAnchorElement,
);
if (!anchor) return null;

let url: URL;
try {
url = new URL(anchor.href);
} catch {
return null;
}
if (url.protocol !== "file:" || url.hostname !== "" || url.search !== "") {
return null;
}

let filePath: string;
try {
filePath = decodeURIComponent(url.pathname);
} catch {
return null;
}
if (!filePath.startsWith("/") || !MARKDOWN_EXTENSION.test(filePath)) {
return null;
}
return { anchor, path: filePath };
}

function openInBb(anchor: HTMLAnchorElement): boolean {
if (!anchor.isConnected) return false;
const fallbackEvent = new MouseEvent("click", {
bubbles: true,
cancelable: true,
button: 0,
});
fallbackEvents.add(fallbackEvent);
return !anchor.dispatchEvent(fallbackEvent);
}

async function requestMossOpen(
pluginId: string,
link: MarkdownFileLink,
): Promise<void> {
try {
const response = await fetch(
`/api/v1/plugins/${encodeURIComponent(pluginId)}/http/open`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path: link.path }),
},
);
if (!response.ok) throw new Error("Moss did not accept the file");
} catch {
const openedInBb = openInBb(link.anchor);
toast.error("Moss couldn’t open this file", {
description: openedInBb
? "It was opened in bb instead."
: "Right-click the link to choose another app.",
});
}
}

export default definePluginApp((app) => {
app.contentScripts.register({
id: "open-markdown-links",
mount({ pluginId }) {
const handleClick = (event: MouseEvent) => {
if (fallbackEvents.has(event)) return;
const link = markdownFileLinkFromClick(event);
if (link === null) return;

event.preventDefault();
event.stopImmediatePropagation();
void requestMossOpen(pluginId, link);
};
document.addEventListener("click", handleClick, true);
return () => document.removeEventListener("click", handleClick, true);
},
});
});
37 changes: 37 additions & 0 deletions plugins/open-in-moss/components/ui/hooks/use-compact-viewport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
createContext,
createElement,
useContext,
type ReactNode,
} from "react";

import { useMediaQuery } from "./use-media-query.js";

export const COMPACT_VIEWPORT_QUERY = "(max-width: 767px)";

const CompactViewportOverrideContext = createContext<boolean | null>(null);

interface CompactViewportOverrideProviderProps {
children: ReactNode;
isCompactViewport: boolean;
}

export function CompactViewportOverrideProvider({
children,
isCompactViewport,
}: CompactViewportOverrideProviderProps) {
return createElement(
CompactViewportOverrideContext.Provider,
{ value: isCompactViewport },
children,
);
}

export function useIsCompactViewport(): boolean {
const override = useContext(CompactViewportOverrideContext);
const isCompactViewport = useMediaQuery(COMPACT_VIEWPORT_QUERY);
if (override !== null) {
return override;
}
return isCompactViewport;
}
Loading
Loading