ext: context-menus.ts — link/media menu items + grab-tab command
docs/05 §3: "Download with Velox" on a link (linkUrl) or a media element (srcUrl) hands that one URL to download.add with the page as referrer; a configurable command (Ctrl+Shift+U) does the same for the active tab. These are explicit user requests, so they skip shouldCapture and the fail-open path — a failure is surfaced to the user via notifications instead. The page/selection "Download all links…" item waits on the content-script link harvester (build-order step 7) and will be added there. manifest: commands.velox-grab-current-tab. 9 tests. 110 total, lint clean. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// Right-click and keyboard entry points.
|
||||
//
|
||||
// docs/05 §3: "Download with Velox" on a link or a media element hands that one URL to
|
||||
// the daemon; a configurable command grabs the active tab's URL the same way. The
|
||||
// page/selection "Download all links…" item waits on the content-script link harvester
|
||||
// (build-order step 7) and is added there.
|
||||
//
|
||||
// These are explicit user requests, so they go straight to download.add — no
|
||||
// shouldCapture, no fail-open dance.
|
||||
|
||||
import type { DownloadSpec } from '../shared/protocol/index.js';
|
||||
|
||||
export const LINK_MENU_ID = 'velox-download-link';
|
||||
export const MEDIA_MENU_ID = 'velox-download-media';
|
||||
export const GRAB_TAB_COMMAND = 'velox-grab-current-tab';
|
||||
|
||||
interface OnClickedInfo {
|
||||
menuItemId: string | number;
|
||||
linkUrl?: string;
|
||||
srcUrl?: string;
|
||||
pageUrl?: string;
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
url?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface Listenable<A extends unknown[]> {
|
||||
addListener(cb: (...args: A) => void): void;
|
||||
removeListener(cb: (...args: A) => void): void;
|
||||
}
|
||||
|
||||
export interface MenusLike {
|
||||
create(props: {
|
||||
id: string;
|
||||
title: string;
|
||||
contexts: string[];
|
||||
}): void;
|
||||
removeAll(): Promise<void> | void;
|
||||
onClicked: Listenable<[OnClickedInfo, Tab | undefined]>;
|
||||
}
|
||||
|
||||
export interface CommandsLike {
|
||||
onCommand: Listenable<[string, Tab | undefined]>;
|
||||
}
|
||||
|
||||
export interface TabsLike {
|
||||
query(q: { active: true; currentWindow: true }): Promise<Tab[]>;
|
||||
}
|
||||
|
||||
export interface ContextMenusDeps {
|
||||
addDownload: (spec: DownloadSpec) => Promise<unknown>;
|
||||
menus: MenusLike;
|
||||
tabs: TabsLike;
|
||||
commands?: CommandsLike;
|
||||
/** Surface a failure to the user (a notification, usually). Optional. */
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export class ContextMenus {
|
||||
private onClicked?: (info: OnClickedInfo, tab: Tab | undefined) => void;
|
||||
private onCommand?: (name: string, tab: Tab | undefined) => void;
|
||||
|
||||
constructor(private readonly deps: ContextMenusDeps) {}
|
||||
|
||||
async register(): Promise<void> {
|
||||
await this.deps.menus.removeAll();
|
||||
this.deps.menus.create({ id: LINK_MENU_ID, title: 'Download with Velox', contexts: ['link'] });
|
||||
this.deps.menus.create({
|
||||
id: MEDIA_MENU_ID,
|
||||
title: 'Download with Velox',
|
||||
contexts: ['image', 'video', 'audio'],
|
||||
});
|
||||
|
||||
this.onClicked = (info, tab) => void this.handleClick(info, tab);
|
||||
this.deps.menus.onClicked.addListener(this.onClicked);
|
||||
|
||||
if (this.deps.commands) {
|
||||
this.onCommand = (name, tab) => void this.handleCommand(name, tab);
|
||||
this.deps.commands.onCommand.addListener(this.onCommand);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.onClicked) this.deps.menus.onClicked.removeListener(this.onClicked);
|
||||
if (this.onCommand && this.deps.commands) this.deps.commands.onCommand.removeListener(this.onCommand);
|
||||
this.onClicked = undefined;
|
||||
this.onCommand = undefined;
|
||||
}
|
||||
|
||||
private async handleClick(info: OnClickedInfo, _tab: Tab | undefined): Promise<void> {
|
||||
let url: string | undefined;
|
||||
if (info.menuItemId === LINK_MENU_ID) url = info.linkUrl;
|
||||
else if (info.menuItemId === MEDIA_MENU_ID) url = info.srcUrl;
|
||||
else return;
|
||||
await this.add(url, info.pageUrl);
|
||||
}
|
||||
|
||||
private async handleCommand(name: string, tab: Tab | undefined): Promise<void> {
|
||||
if (name !== GRAB_TAB_COMMAND) return;
|
||||
const active = tab ?? (await this.deps.tabs.query({ active: true, currentWindow: true }))[0];
|
||||
await this.add(active?.url, active?.url);
|
||||
}
|
||||
|
||||
private async add(url: string | undefined, referrer: string | undefined): Promise<void> {
|
||||
if (!url || !/^https?:/i.test(url)) {
|
||||
this.deps.onError?.('Velox can only download http(s) links.');
|
||||
return;
|
||||
}
|
||||
const spec: DownloadSpec = { url };
|
||||
if (referrer) spec.referrer = referrer;
|
||||
try {
|
||||
await this.deps.addDownload(spec);
|
||||
} catch (err) {
|
||||
this.deps.onError?.(`Velox could not start the download: ${describe(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function describe(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
Reference in New Issue
Block a user