Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jobs:
- http-codes
- obsidian
- paper-size
- tutorial
- xkcd

steps:
Expand Down
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ resources = [
{ src = "plugins/quick-links/manifest.json", target = "plugins/quick-links/manifest.json" },
{ src = "plugins/quick-links/plugin.js", target = "plugins/quick-links/plugin.js" },
{ src = "plugins/quick-links/links.json", target = "plugins/quick-links/links.json" },
{ src = "plugins/tutorial/manifest.json", target = "plugins/tutorial/manifest.json" },
{ src = "plugins/tutorial/plugin.js", target = "plugins/tutorial/plugin.js" },
{ src = "plugins/unit-conversions/manifest.json", target = "plugins/unit-conversions/manifest.json" },
{ src = "plugins/unit-conversions/plugin.js", target = "plugins/unit-conversions/plugin.js" },
{ src = "plugins/web-search/manifest.json", target = "plugins/web-search/manifest.json" },
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,15 @@ Type to query, ↑/↓ to highlight, Enter to invoke, Esc to dismiss. The
Core built-in handles `settings`, `install <manifest-url>`, `reload`,
`update`, `shutdown`, `sleep`, `lock`, etc.

`highbeam --query "<text>"` opens with the query box pre-filled (forwarded
to a running daemon when one exists).

## Plugins

Single-directory `manifest.json` + `plugin.js`. Reference plugins
under `plugins/`; bundled ones get seeded on first launch. Authoring
under `plugins/`; bundled ones get seeded on first launch. The bundled
`tutorial` opens automatically on the first launch; type `tutorial` (or
`help`) to revisit it. Authoring
guide: [docs/plugin-authoring.md](docs/plugin-authoring.md). API
reference: [docs/sdk-reference.md](docs/sdk-reference.md). Dynamic,
stateful screens: [docs/views.md](docs/views.md).
Expand Down
10 changes: 9 additions & 1 deletion plugins/package-lock.json

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

11 changes: 11 additions & 0 deletions plugins/tutorial/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "tutorial",
"displayName": "Tutorial",
"version": "0.1.0",
"description": "First-launch getting-started guide. Shown automatically the first time High Beam runs; reachable any time by typing 'tutorial', 'help', or 'welcome'.",
"entry": "plugin.js",
"timeoutMs": 500,
"memoryMb": 32,
"capabilities": ["actions"],
"defaultEnabled": true
}
8 changes: 8 additions & 0 deletions plugins/tutorial/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@high-beam-plugin/tutorial",
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "vitest run"
}
}
57 changes: 57 additions & 0 deletions plugins/tutorial/plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// tutorial — first-launch getting-started guide.
//
// Shown automatically the first time High Beam runs (the daemon fires the
// preset `tutorial` query and auto-opens this view). Reachable any time by
// typing `tutorial`, `help`, or `welcome`.

import { showView, openUrl, closeView } from 'highbeam:actions';
import { Heading, Text, Divider, Stack, Button } from 'highbeam:view';

const REPO_URL = 'https://github.com/Mechazawa/high-beam';

export const TutorialView = {
setup: () => ({}),

render() {
return {
title: 'Welcome to High Beam',
body: [
Heading({ text: 'Welcome to High Beam' }),
Text({ text: 'A keyboard launcher. Here is everything you need to get going.' }),
Divider(),
Text({ text: 'Open it', size: 'lg' }),
Text({ text: 'macOS: Shift+Space. Linux: bind highbeam --open to a hotkey.', tone: 'muted' }),
Text({ text: 'Search', size: 'lg' }),
Text({ text: 'Type to query, Up/Down to highlight, Enter to run, Esc to dismiss.', tone: 'muted' }),
Text({ text: 'Built-in verbs', size: 'lg' }),
Text({ text: 'Type settings, install <manifest-url>, reload, or update.', tone: 'muted' }),
Text({ text: 'Plugins', size: 'lg' }),
Text({ text: 'Features are single-file JS plugins. Add more with install <manifest-url>.', tone: 'muted' }),
Divider(),
Stack({
direction: 'h',
gap: 'sm',
children: [
Button({ label: 'Read the docs', tone: 'primary', onClick: openUrl(REPO_URL) }),
Button({ label: 'Got it', onClick: closeView }),
],
}),
],
};
},
};

const KEYWORDS = ['tutorial', 'help', 'welcome'];

export async function* query(input) {
const q = input.trim().toLowerCase();
if (q.length < 3) return;
if (!KEYWORDS.some((keyword) => keyword.startsWith(q))) return;
yield {
key: 'tutorial-open',
title: 'High Beam tutorial',
subtitle: 'Getting started with the launcher',
weight: 100,
action: showView(TutorialView),
};
}
39 changes: 39 additions & 0 deletions plugins/tutorial/tutorial.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { query, TutorialView } from './plugin.js';

async function collect(iter) {
const out = [];
for await (const item of iter) out.push(item);
return out;
}

describe('tutorial plugin', () => {
it('answers the tutorial / help / welcome keywords (and their prefixes)', async () => {
for (const q of ['tutorial', 'help', 'welcome', 'tut', 'hel']) {
const results = await collect(query(q));
expect(results, q).toHaveLength(1);
expect(results[0]).toMatchObject({
key: 'tutorial-open',
action: { kind: 'showView' },
});
}
});

it('stays silent for unrelated or too-short queries', async () => {
for (const q of ['', 'a', 'to', 'calc', 'smoke']) {
expect(await collect(query(q)), q).toHaveLength(0);
}
});

it('renders the getting-started view with action buttons', () => {
const tree = TutorialView.render.call(TutorialView.setup());

expect(tree.title).toBe('Welcome to High Beam');
expect(tree.body[0]).toMatchObject({ kind: 'heading', text: 'Welcome to High Beam' });

const actions = tree.body.find((block) => block.kind === 'stack');
const buttons = actions.children.filter((child) => child.kind === 'button');
expect(buttons).toHaveLength(2);
expect(buttons.map((b) => b.onClick.kind)).toEqual(['openUrl', 'closeView']);
});
});
2 changes: 2 additions & 0 deletions plugins/tutorial/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import config from '../../sdk/highbeam/vitest.config.example';
export default config;
44 changes: 33 additions & 11 deletions src/app/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,15 +347,30 @@ fn invoke_selected(
) {
let Some(w) = weak.upgrade() else { return };

let idx = usize::try_from(w.get_selected_index().max(0)).unwrap_or(0);
let query_text = w.get_query_text().to_string();
// A non-empty `auto-invoke-plugin` means this fire was machine-initiated
// by the first-launch tutorial, not a user pressing Enter. Resolve the
// target by plugin name against the live `latest` (not `selected-index`,
// which is read from a possibly-stale render snapshot) so a yield that
// re-sorted the list can't make us run the wrong result; leave the flag
// armed until the target actually appears so a later render retries.
let auto_target = w.get_auto_invoke_plugin();
let is_auto = !auto_target.is_empty();
let snapshot = match latest.lock() {
Ok(s) => s,
Err(err) => {
tracing::error!(%err, "plugins: latest results lock poisoned");
return;
}
};
let idx = if is_auto {
let Some(found) = snapshot.iter().position(|r| r.plugin_name == auto_target.as_str()) else {
return;
};

found
} else {
usize::try_from(w.get_selected_index().max(0)).unwrap_or(0)
};
let Some(picked) = snapshot.get(idx) else {
return;
};
Expand All @@ -376,18 +391,25 @@ fn invoke_selected(
let result_key = picked.result.key.clone();
drop(snapshot);

// Push the query to history before the action runs — if the action hides
// the window we still want the entry recorded.
push_history(
history_db,
history_state,
&query_text,
settings.query_history_max_entries(),
);
if is_auto {
// One-shot: now that the target resolved we've committed to firing.
w.set_auto_invoke_plugin(slint::SharedString::new());
} else {
// Push the query to history before the action runs — if the action
// hides the window we still want the entry recorded. The synthetic
// auto-invoke is skipped: it must not seed history or earn frecency.
let query_text = w.get_query_text().to_string();
push_history(
history_db,
history_state,
&query_text,
settings.query_history_max_entries(),
);
}

match actions::execute(&action) {
Ok(outcome) => {
if let Some(db) = frecency_db {
if !is_auto && let Some(db) = frecency_db {
spawn_pick_bump(db, plugin_name.clone(), result_key);
}

Expand Down
13 changes: 13 additions & 0 deletions src/app/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ pub(super) fn render_results(window: &QueryWindow, results: &[RankedResult]) {
if previously_selected >= row_count || previously_selected < 0 {
window.set_selected_index(0);
}

maybe_auto_invoke(window);
}

/// First-launch hook: while `auto-invoke-plugin` is armed, drive the normal
/// invoke path on every render. `invoke_selected` resolves the named plugin's
/// result against the live results and clears the flag once it fires, so this
/// runs exactly once and can never hit a different plugin answering the same
/// query.
fn maybe_auto_invoke(window: &QueryWindow) {
if !window.get_auto_invoke_plugin().is_empty() {
window.invoke_invoke_selected(false, false, false, false);
}
}

/// Anything that isn't a base64 data URI is treated as "no icon" so the row
Expand Down
46 changes: 29 additions & 17 deletions src/bundle_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,21 @@ use crate::logging::LogErr;
/// Install bundled default plugins into the user's plugin directory if the
/// directory is empty or absent. Errors are logged and swallowed — a failed
/// install must not prevent the daemon from booting.
pub fn install_default_plugins_if_needed() {
///
/// Returns `true` only when this call actually seeded the directory — i.e.
/// the first launch. Every skip path (already populated, unbundled, stat or
/// copy failure) returns `false`.
pub fn install_default_plugins_if_needed() -> bool {
let Some(user_dir) = crate::paths::plugins_dir() else {
tracing::debug!("bundle-install: no platform plugin dir; skipping");
return;
return false;
};

let Some(bundled) = bundled_plugins_dir() else {
// Running unbundled (cargo run) — not an error.
tracing::debug!("bundle-install: no bundled resources; running unbundled");

return;
return false;
};

match user_dir_needs_seeding(&user_dir) {
Expand All @@ -40,7 +44,7 @@ pub fn install_default_plugins_if_needed() {
"bundle-install: user plugin dir already populated"
);

return;
return false;
}
Ok(true) => {}
Err(err) => {
Expand All @@ -50,7 +54,7 @@ pub fn install_default_plugins_if_needed() {
"bundle-install: could not stat user plugin dir; skipping install",
);

return;
return false;
}
}

Expand All @@ -61,21 +65,29 @@ pub fn install_default_plugins_if_needed() {
"bundle-install: failed to create user plugin dir",
);

return;
return false;
}

match copy_dir_recursive(&bundled, &user_dir) {
Ok(()) => tracing::info!(
plugins_dir = %user_dir.display(),
source = %bundled.display(),
"bundle-install: copied default plugins into user dir",
),
Err(err) => tracing::warn!(
source = %bundled.display(),
target = %user_dir.display(),
%err,
"bundle-install: copy failed; user must install plugins manually",
),
Ok(()) => {
tracing::info!(
plugins_dir = %user_dir.display(),
source = %bundled.display(),
"bundle-install: copied default plugins into user dir",
);

true
}
Err(err) => {
tracing::warn!(
source = %bundled.display(),
target = %user_dir.display(),
%err,
"bundle-install: copy failed; user must install plugins manually",
);

false
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ pub struct Args {
/// present) or the platform plugin dir.
#[arg(long, value_name = "PATH")]
pub plugins_dir: Option<PathBuf>,

/// Open the launcher with the query box pre-filled with this text, as if
/// the user had typed it. Forwarded to a running daemon when one exists,
/// otherwise the cold-started daemon opens with it. Implies `--open`.
#[arg(long, value_name = "TEXT")]
pub query: Option<String>,
}
Loading
Loading