use std::collections::HashSet;
use anyhow::Result;
use super::edits::EditTarget;
use super::ui::{HintsMode, Mode};
use super::{ClientId, Server};
use crate::hints::{self, Kind, Match};
use crate::input::{InputEvent, KeyCode, MouseKind};
use crate::layout::PaneId;
use crate::protocol::Placement;
use crate::shell;
use crate::tools::{Open, ToolDef, Where};
fn remote_path(file: &str, cwd: Option<&std::path::Path>) -> String {
match cwd {
Some(dir) if !file.starts_with('/') && !file.starts_with('~') => {
let dir = dir.display().to_string();
format!("{}/{}", dir.trim_end_matches('/'), file.strip_prefix("./").unwrap_or(file))
}
_ => file.to_string(),
}
}
fn is_loopback(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "0.0.0.0" | "::1" | "::" | "")
}
impl Server {
pub(super) fn start_hints(&mut self, id: ClientId) -> Mode {
let Some(pane) = self.client_focus(id) else { return Mode::Normal };
let Some(rows) = self.panes.get(&pane).filter(|p| p.is_terminal()).map(|p| p.screen_rows()) else {
self.toast(id, "hints work in terminal panes");
return Mode::Normal;
};
let mut servers = HashSet::new();
for k in &self.known_servers {
servers.insert(k.server.name.clone());
servers.insert(k.server.host.clone());
}
let found = hints::find(&rows, &servers, &self.hint_patterns);
let hints = hints::label(found, &self.config.hints.alphabet);
if hints.is_empty() {
self.toast(id, "nothing to act on in this pane");
return Mode::Normal;
}
let snapshot = self.pane_snapshot(id, pane);
Mode::Hints(HintsMode { pane, hints, typed: String::new(), act: false, snapshot })
}
pub(super) fn on_hints(&mut self, id: ClientId, mut h: HintsMode, ev: InputEvent) -> Mode {
let k = match ev {
InputEvent::Key(k) => k,
InputEvent::Mouse(m) if matches!(m.kind, MouseKind::Press(_)) => return Mode::Normal,
_ => return Mode::Hints(h),
};
match k.code {
KeyCode::Backspace => {
h.typed.pop();
}
KeyCode::Tab => h.act = !h.act,
KeyCode::Char(c) if c.is_ascii_alphabetic() && !k.mods.ctrl && !k.mods.alt => {
if h.snapshot.as_ref().is_some_and(|s| self.pane_inner_rect(id, h.pane) != Some(s.area)) {
self.toast(id, "The pane moved since the labels were made: ask for hints again.");
return Mode::Normal;
}
h.typed.push(c.to_ascii_lowercase());
if let Some(found) = h.hints.iter().find(|x| x.label == h.typed) {
let m = found.m.clone();
return match self.hint_chosen(id, h.pane, m, h.act) {
Ok(next) => next,
Err(e) => {
self.toast(id, format!("{e:#}"));
Mode::Normal
}
};
}
if !h.hints.iter().any(|x| x.label.starts_with(&h.typed)) {
h.typed.pop();
}
}
_ => return Mode::Normal,
}
Mode::Hints(h)
}
fn pane_snapshot(&mut self, client: ClientId, pane: PaneId) -> Option<Box<ratatui::buffer::Buffer>> {
let wi = self.clients.get(&client).and_then(|c| self.ws_index(c.workspace))?;
let (_, outer, floating) =
self.tab_rects(self.workspaces[wi].tab(), self.layout_area).into_iter().find(|(p, _, _)| *p == pane)?;
let inner = self.inner(outer, floating);
let mut buf = ratatui::buffer::Buffer::empty(inner);
let mut links = crate::ansi::Links::new();
self.panes.get_mut(&pane)?.render(inner, &mut buf, &mut links);
Some(Box::new(buf))
}
fn hint_chosen(&mut self, id: ClientId, pane: PaneId, m: Match, act: bool) -> Result<Mode> {
if !act {
self.copy_to_client(id, &m.text);
return Ok(Mode::Normal);
}
match m.kind {
Kind::Url => self.open_url_from(id, pane, &m.text)?,
Kind::Address => self.open_url_from(id, pane, &format!("http://{}", m.text))?,
Kind::Ip | Kind::Server => {
let (user, host) = match m.text.rsplit_once('@') {
Some((u, h)) => (Some(u.to_string()), h),
None => (None, m.text.as_str()),
};
let mut server = self
.known_servers
.iter()
.find(|k| k.server.name == host || k.server.host == host)
.map(|k| k.server.clone())
.or_else(|| self.resolve_server(host))
.ok_or_else(|| anyhow::anyhow!("not a server: {host}"))?;
if user.is_some() {
server.user = user;
}
let ws = self.clients.get(&id).map(|c| c.workspace).unwrap_or(0);
self.open_ssh(ws, Some(pane), &server, Placement::Auto)?;
}
Kind::Path => {
if let Some(server) = self.pane_server_entry(pane) {
return Ok(self.edit_hinted(id, pane, server, &m.text));
}
let (file, line) = hints::split_line(&m.text);
let file = match file.strip_prefix("~/") {
Some(rest) => format!("\"$HOME\"/{}", shell::quote(rest)),
None => shell::quote(file),
};
let at = line.map(|l| format!(" +{l}")).unwrap_or_default();
self.run_here(id, pane, "edit", format!("${{EDITOR:-vi}}{at} {file}"), &m.text)?;
}
Kind::Hash => {
let h = shell::quote(&m.text);
let cmd = format!(
"if git rev-parse -q --verify {h}^{{commit}} >/dev/null 2>&1; then git show {h}; \
else docker logs -f --tail 200 {h}; fi"
);
self.run_here(id, pane, &m.text.chars().take(9).collect::<String>(), cmd, &m.text)?;
}
Kind::Pod => {
let cmd = format!(
"kubectl logs -f --tail=200 ${{KRAAL_KUBE_CONTEXT:+--context \"$KRAAL_KUBE_CONTEXT\"}} \
${{KRAAL_KUBE_NAMESPACE:+-n \"$KRAAL_KUBE_NAMESPACE\"}} {}",
shell::quote(&m.text)
);
self.run_here(id, pane, &m.text, cmd, &m.text)?;
}
Kind::Uuid => {
if let Some(p) = self.panes.get_mut(&pane)
&& p.exit_code.is_none()
{
p.write_input(m.text.into_bytes());
}
}
Kind::Custom(i) => match self.hint_patterns.patterns.get(i).and_then(|(_, _, tool)| tool.clone()) {
Some(tool) => return Ok(self.tool_request(id, Some(pane), &tool, Some(m.text))),
None => self.copy_to_client(id, &m.text),
},
}
Ok(Mode::Normal)
}
fn edit_hinted(&mut self, id: ClientId, pane: PaneId, server: crate::servers::Server, text: &str) -> Mode {
let (file, line) = hints::split_line(text);
let cwd = self.panes.get(&pane).and_then(|p| p.remote_cwd());
let mut ssh = self.ssh_options(&server);
ssh.push(server.destination());
let target = EditTarget {
server: server.name.clone(),
ssh,
path: remote_path(file, cwd.as_deref()),
line,
ws: self.clients.get(&id).map(|c| c.workspace),
near: Some(pane),
};
let production = self.pane_production(pane);
self.edit_request(id, target, production)
}
fn open_url_from(&mut self, id: ClientId, pane: PaneId, url: &str) -> Result<()> {
let (Some((port, rest)), Some(server)) = (hints::loopback_port(url), self.pane_server(pane)) else {
self.open_for(id, url);
return Ok(());
};
let existing = self
.tunnels
.iter()
.find(|t| {
t.spec.remote_port == port
&& is_loopback(&t.spec.remote_host)
&& match &t.spec.via {
Some(via) => via.same_connection(&server),
None => t.spec.server == server.name,
}
})
.map(|t| (t.id, t.spec.local));
let (tunnel, local) = match existing {
Some(found) => found,
None => {
let info = self.add_tunnel_to(server.clone(), &port.to_string(), None)?;
self.toast(id, format!("tunnel localhost:{} → {}:{port}…", info.spec.local, server.name));
(info.id, info.spec.local)
}
};
let scheme = url.split("://").next().unwrap_or("http");
self.pending_opens.push((id, tunnel, format!("{scheme}://localhost:{local}{rest}")));
self.open_pending();
Ok(())
}
fn run_here(&mut self, id: ClientId, pane: PaneId, label: &str, command: String, selection: &str) -> Result<()> {
let tool = ToolDef {
command,
description: String::new(),
key: None,
on: Where::Pane,
open: Open::Floating,
hold: false,
when: Vec::new(),
enabled: true,
read_only: true,
};
let mut ctx = self.tool_context(pane);
ctx.selection = Some(selection.to_string());
let spec = tool.spec(&ctx, &|s, remote| self.ssh_command_running(s, remote));
ToolDef::check_cwd(&spec)?;
let ws = self.clients.get(&id).map(|c| c.workspace).unwrap_or(0);
self.spawn_pane_in(ws, Some(pane), spec, Placement::Floating, Some(label.to_string()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::remote_path;
use std::path::Path;
#[test]
fn a_path_on_a_server_is_read_where_its_pane_is() {
let cwd = Some(Path::new("/srv/app/"));
assert_eq!(remote_path("src/main.rs", cwd), "/srv/app/src/main.rs");
assert_eq!(remote_path("./conf/x.yml", cwd), "/srv/app/conf/x.yml");
assert_eq!(remote_path("../shared/.env", cwd), "/srv/app/../shared/.env");
assert_eq!(remote_path("/etc/hosts", cwd), "/etc/hosts");
assert_eq!(remote_path("~/notes", cwd), "~/notes");
assert_eq!(remote_path("src/main.rs", None), "src/main.rs");
}
}