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
35 changes: 35 additions & 0 deletions .design/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,41 @@ Optional bottom section. Hidden entirely when no plugins are configured.
| Loading | **Proposed**: title with trailing spinner (see `loading.md`), no rows. |
| Error | Title + single `text_xs` row in `#ff6b6b` containing `panel.error`. (`app.rs:815-821`) |

### Hint labels

**Renderer**: `AuraView::render_plugin_controls` (hint mode, `f`).

While hint mode is on, each button in a `controls` section gets a label
badge before its content, and a status line sits above the rows.

- Badge: `px_1`, `rounded_sm`, bg `COLOR_ACCENT`, text `COLOR_BG`, bold.
Characters already typed render at 50% opacity before the rest.
- Buttons the typed characters rule out: whole pill at 35% opacity.
- Status line: `text_xs` / `COLOR_TEXT_DIM`.

### Plugin key panel

**Renderer**: `AuraView::render_leader_panel`.

Floating card for plugin shortcuts, drawn over the content at the bottom
right while leader mode is on (the plugin leader, default `space`, was
pressed).

- Position: `absolute`, `right 12px`, `bottom 12px`, width `210px`.
- Card: bg `COLOR_SURFACE`, border `COLOR_BORDER`, `rounded_md`, `p_2`,
`gap_1`, `shadow_lg`, `text_xs`.
- Header: the leader and the keys typed so far as chips, then `…`;
border-bottom `COLOR_BORDER`.
- Rows: a chip with the strokes still to press, then the label in
`COLOR_TEXT_DIM`.
- Chip: `px_1`, `rounded_sm`, bg `COLOR_SURFACE_HI`, text `COLOR_TEXT`.
- Footer: "esc to cancel", `COLOR_TEXT_DIM` at 70% opacity.

| State | Rendering |
| ------- | ------------------------------------------------------------------------- |
| Leader | Header, one row per key still reachable, footer. |
| Confirm | A key armed an action whose button isn't on screen: border and prompt in `COLOR_ERROR`, then "press … again · esc to cancel" in `COLOR_TEXT_DIM`. |

## Loading body

**Renderer**: `render_loading` — `app.rs:426-435`.
Expand Down
15 changes: 10 additions & 5 deletions crates/aura-cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,12 @@ fn collect() -> DoctorReport {
let keymap_path = Keymap::default_path();
let plugins_dir = plugin::user_plugins_dir();
let mut keybindings_enabled = true;
let mut plugin_leader = aura_core::plugin::keys::DEFAULT_LEADER.to_string();

let (config_status, agents, plugins) = match AppConfig::load_with_discovery(&config_path) {
Ok(cfg) => {
keybindings_enabled = cfg.keybindings.enabled;
plugin_leader = cfg.keybindings.plugin_leader.clone();
let agents: Vec<AgentRow> = cfg
.agents
.iter()
Expand Down Expand Up @@ -196,14 +198,17 @@ fn collect() -> DoctorReport {
}
};

let keymap = Keymap::load(&keymap_path);
let mut keymap_warnings: Vec<String> =
keymap.warnings.iter().map(|w| w.message.clone()).collect();
keymap_warnings.extend(aura_core::plugin::keys::leader_warnings(
&plugin_leader,
&keymap,
));
let keybindings = KeybindingsStatus {
enabled: keybindings_enabled,
exists: keymap_path.exists(),
warnings: Keymap::load(&keymap_path)
.warnings
.into_iter()
.map(|w| w.message)
.collect(),
warnings: keymap_warnings,
};

DoctorReport {
Expand Down
18 changes: 14 additions & 4 deletions crates/aura-cli/src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,17 @@ fn run_get(path: &Path, keys: &str, context: BindingContext, format: OutputForma
}

fn run_validate(path: &Path, format: OutputFormat) -> Result<()> {
let keymap = Keymap::load(path);
let mut keymap = Keymap::load(path);
// `plugin_leader` lives in config.toml but only matters against the
// keymap, so it's checked here too.
let leader = aura_core::config::AppConfig::load(&aura_core::config::AppConfig::default_path())
.map(|c| c.keybindings.plugin_leader)
.unwrap_or_else(|_| aura_core::plugin::keys::DEFAULT_LEADER.to_string());
keymap.warnings.extend(
aura_core::plugin::keys::leader_warnings(&leader, &keymap)
.into_iter()
.map(|message| aura_core::keymap::KeymapWarning { message }),
);
match format {
OutputFormat::Json => print_json(&keymap.warnings)?,
OutputFormat::Text => {
Expand All @@ -480,9 +490,9 @@ fn run_validate(path: &Path, format: OutputFormat) -> Result<()> {
println!("{}: OK", path.display());
} else {
println!("{}:", path.display());
for w in &keymap.warnings {
println!(" warning: {w}");
}
}
for w in &keymap.warnings {
println!(" warning: {w}");
}
}
}
Expand Down
27 changes: 27 additions & 0 deletions crates/aura-cli/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,5 +202,32 @@ fn run_plugin(name: &str, period: aura_core::reader::Period, action: Option<&str
Some(id) => PluginRunner::run_action(plugin_cfg, id, period),
None => PluginRunner::run_with_period(plugin_cfg, period),
};
report_keys(&panel, &config.keybindings.plugin_leader);
print_json(&panel)
}

/// Each section's keys as the modal would install them, and any problems
/// with them, on stderr so stdout stays the panel JSON.
fn report_keys(panel: &aura_core::plugin::PluginPanel, raw_leader: &str) {
use aura_core::plugin::keys;
let (leader, error) = keys::effective_leader(raw_leader);
if let Some(e) = error {
eprintln!("warning: {e}");
}
let Some(leader) = leader else {
if panel.sections.iter().any(|s| !s.keys.is_empty()) {
eprintln!("plugin keys are off (keybindings.plugin_leader = \"none\")");
}
return;
};
for section in panel.sections.iter().filter(|s| !s.keys.is_empty()) {
let (bindings, warnings) = keys::resolve(section, &leader);
eprintln!("keys in section `{}`:", section.id);
for b in &bindings {
eprintln!(" {:<14} {} ({})", b.display, b.label, b.action);
}
for w in &warnings {
eprintln!(" warning: {w}");
}
}
}
20 changes: 19 additions & 1 deletion crates/aura-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,29 @@ pub struct KeybindingsConfig {
/// mouse-only, apart from Escape closing it, which works either way.
#[serde(default = "default_true")]
pub enabled: bool,
/// Keystroke(s) in front of every plugin-declared key (see
/// [`crate::plugin::keys`]): with the default `"space"`, a plugin's `s`
/// key is pressed as `space s`. `"none"` turns plugin keys off.
#[serde(default = "default_plugin_leader")]
pub plugin_leader: String,
/// How long, in milliseconds, the host waits for the next plugin key
/// after the leader (or after a key that starts a longer one) before
/// giving up. `None` (the default) waits until Escape.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub leader_timeout_ms: Option<u32>,
}

fn default_plugin_leader() -> String {
crate::plugin::keys::DEFAULT_LEADER.to_string()
}

impl Default for KeybindingsConfig {
fn default() -> Self {
Self { enabled: true }
Self {
enabled: true,
plugin_leader: default_plugin_leader(),
leader_timeout_ms: None,
}
}
}

Expand Down
57 changes: 56 additions & 1 deletion crates/aura-core/src/config_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,31 @@ pub fn fields() -> &'static [FieldDescriptor] {
keybindings.toml.",
example: "true",
},
FieldDescriptor {
key: "keybindings.plugin_leader",
type_label: "string",
allowed: &[],
default: "\"space\"",
summary: "Key pressed before a plugin's own shortcuts.",
description: "Plugins can declare shortcuts for their buttons and actions. Every one \
sits behind this leader, so with the default \"space\" a plugin's `s` key is \
pressed as `space s`, and no plugin key can take over one of Aura's own \
shortcuts. Use keybindings.toml syntax (`space`, `ctrl-p`, `g p`). Set \"none\" \
to turn plugin shortcuts off. An invalid value falls back to \"space\".",
example: "space",
},
FieldDescriptor {
key: "keybindings.leader_timeout_ms",
type_label: "u32?",
allowed: &[],
default: "unset (wait until Escape)",
summary: "How long to wait for a plugin key after the leader.",
description: "After the plugin leader, the key panel waits for the rest of a \
plugin shortcut. Unset (the default), it waits until you finish the shortcut \
or press Escape. Set a number of milliseconds to give up after that long \
without a key instead; each key restarts the wait. `none` unsets it.",
example: "3000",
},
// ── [sponsor] ──
FieldDescriptor {
key: "sponsor.nudge",
Expand Down Expand Up @@ -533,6 +558,12 @@ pub fn get_value(cfg: &AppConfig, key: &str) -> Result<String, SchemaError> {
.unwrap_or_else(|| "(unset)".to_string()),
"update.dismiss_all" => cfg.update.dismiss_all.to_string(),
"keybindings.enabled" => cfg.keybindings.enabled.to_string(),
"keybindings.plugin_leader" => cfg.keybindings.plugin_leader.clone(),
"keybindings.leader_timeout_ms" => cfg
.keybindings
.leader_timeout_ms
.map(|n| n.to_string())
.unwrap_or_else(|| "(unset)".to_string()),
"sponsor.nudge" => cfg.sponsor.nudge.to_string(),
_ => return Err(unknown_key(key)),
};
Expand Down Expand Up @@ -568,6 +599,10 @@ pub fn set_value(cfg: &mut AppConfig, key: &str, raw: &str) -> Result<(), Schema
"update.dismissed_version" => cfg.update.dismissed_version = parse_opt_string(raw),
"update.dismiss_all" => cfg.update.dismiss_all = parse_bool(key, raw)?,
"keybindings.enabled" => cfg.keybindings.enabled = parse_bool(key, raw)?,
"keybindings.plugin_leader" => cfg.keybindings.plugin_leader = parse_leader(key, raw)?,
"keybindings.leader_timeout_ms" => {
cfg.keybindings.leader_timeout_ms = parse_opt_u32(key, raw)?
}
"sponsor.nudge" => cfg.sponsor.nudge = parse_bool(key, raw)?,
_ => return Err(unknown_key(key)),
}
Expand All @@ -589,6 +624,18 @@ fn parse_enum(
}
}

fn parse_leader(key: &str, raw: &str) -> Result<String, SchemaError> {
match crate::plugin::keys::parse_leader(raw) {
Ok(Some(keys)) => Ok(keys.display),
Ok(None) => Ok("none".to_string()),
Err(_) => Err(SchemaError::InvalidType {
key: key.to_string(),
expected: "a keystroke such as `space` or `ctrl-p`, or `none`",
value: raw.to_string(),
}),
}
}

fn parse_bool(key: &str, raw: &str) -> Result<bool, SchemaError> {
match raw.to_ascii_lowercase().as_str() {
"true" | "yes" | "on" | "1" => Ok(true),
Expand Down Expand Up @@ -744,6 +791,10 @@ fn toml_rhs(cfg: &AppConfig, key: &str) -> Option<String> {
"update.dismissed_version" => return cfg.update.dismissed_version.as_deref().map(quote),
"update.dismiss_all" => cfg.update.dismiss_all.to_string(),
"keybindings.enabled" => cfg.keybindings.enabled.to_string(),
"keybindings.plugin_leader" => quote(&cfg.keybindings.plugin_leader),
"keybindings.leader_timeout_ms" => {
return cfg.keybindings.leader_timeout_ms.map(|n| n.to_string())
}
"sponsor.nudge" => cfg.sponsor.nudge.to_string(),
_ => return None,
})
Expand Down Expand Up @@ -819,6 +870,7 @@ mod tests {
cfg.window.auto_resize = Some(false);
cfg.window.max_height = Some(500);
cfg.update.dismissed_version = Some("0.0.0".to_string());
cfg.keybindings.leader_timeout_ms = Some(3000);
cfg
}

Expand Down Expand Up @@ -1003,7 +1055,10 @@ mod tests {
dismissed_version: Some("0.1.18".to_string()),
dismiss_all: true,
},
keybindings: KeybindingsConfig { enabled: false },
keybindings: KeybindingsConfig {
enabled: false,
..Default::default()
},
sponsor: SponsorConfig { nudge: false },
};
assert_round_trips(&cfg);
Expand Down
8 changes: 8 additions & 0 deletions crates/aura-core/src/keymap/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ pub enum KeyAction {
OpenKeybindings,
OpenUpdate,
DismissUpdate,
HintMode,
}

/// How the help overlay and `aura keys list` group actions.
Expand Down Expand Up @@ -249,6 +250,12 @@ const ACTIONS: &[ActionInfo] = &[
Commands,
"Hide the update button",
),
info(
A::HintMode,
"hint_mode",
Commands,
"Label plugin buttons to press them by key",
),
];

impl KeyAction {
Expand Down Expand Up @@ -666,6 +673,7 @@ const DEFAULTS: &[(BindingContext, &str, KeyAction)] = {
(G, "t", A::OpenTheme),
(G, "u", A::OpenUpdate),
(G, "U", A::DismissUpdate),
(G, "f", A::HintMode),
(O, "escape", A::CloseOverlay),
]
};
Expand Down
35 changes: 34 additions & 1 deletion crates/aura-core/src/plugin/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,12 @@ pub fn add_plugin(plugins_dir: &Path, opts: AddOptions) -> Result<AddOutcome> {
}

if opts.symlink {
symlink_file(&opts.source, &dest)?;
// A symlink target resolves against the link's own directory, so a
// relative source (`target/release/foo`, as typed from a checkout)
// would dangle inside the plugins dir. Link to the absolute path.
let target = fs::canonicalize(&opts.source)
.with_context(|| format!("resolve {}", opts.source.display()))?;
symlink_file(&target, &dest)?;
} else {
fs::copy(&opts.source, &dest)
.with_context(|| format!("copy {} -> {}", opts.source.display(), dest.display()))?;
Expand Down Expand Up @@ -556,6 +561,34 @@ icon = "icons/demo.svg"
assert!(outcome.sidecar.is_none());
}

#[cfg(unix)]
#[test]
fn add_plugin_symlinks_to_the_absolute_source() {
let src_dir = tempdir().unwrap();
let plugins_dir = tempdir().unwrap();
let src = write_exec(src_dir.path(), "aura-plugin-linked", "echo {}\n");
fs::create_dir(src_dir.path().join("sub")).unwrap();
// A non-canonical spelling of the source must not leak into the link.
let indirect = src_dir.path().join("sub/../aura-plugin-linked");

let outcome = add_plugin(
plugins_dir.path(),
AddOptions {
source: indirect,
dest_name: None,
symlink: true,
name: None,
color: None,
icon: None,
},
)
.unwrap();
let target = fs::read_link(&outcome.installed).unwrap();
assert!(target.is_absolute());
assert_eq!(target, fs::canonicalize(&src).unwrap());
assert!(outcome.installed.is_file());
}

#[cfg(unix)]
#[test]
fn remove_plugin_deletes_binary_and_sidecar() {
Expand Down
Loading
Loading