pull/13796/merge
Ashkan Kiani 2025-07-24 20:22:33 +02:00 committed by GitHub
commit b6dcd1ab31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 582 additions and 25 deletions

4
Cargo.lock generated
View File

@ -126,9 +126,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.16.0"
version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]]
name = "byteorder"

View File

@ -1,6 +1,6 @@
use arc_swap::{access::Map, ArcSwap};
use futures_util::Stream;
use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Range, Selection};
use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Range, RopeSlice, Selection};
use helix_lsp::{
lsp::{self, notification::Notification},
util::lsp_range_to_range,
@ -16,7 +16,12 @@ use helix_view::{
tree::Layout,
Align, Editor,
};
use once_cell::sync::Lazy;
use serde_json::json;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
sync::mpsc::{Receiver, Sender},
};
use tui::backend::Backend;
use crate::{
@ -25,18 +30,23 @@ use crate::{
config::Config,
handlers,
job::Jobs,
keymap::Keymaps,
keymap::{Keymaps, MappableCommand},
ui::{self, overlay::overlaid},
};
use log::{debug, error, info, warn};
#[cfg(not(feature = "integration"))]
use std::io::stdout;
use std::{io::stdin, path::Path, sync::Arc};
use std::{
borrow::Cow,
io::stdin,
path::{Path, PathBuf},
sync::Arc,
};
#[cfg(not(windows))]
use anyhow::Context;
use anyhow::Error;
use anyhow::{anyhow, Error};
use crossterm::{event::Event as CrosstermEvent, tty::IsTty};
#[cfg(not(windows))]
@ -68,6 +78,8 @@ pub struct Application {
signals: Signals,
jobs: Jobs,
lsp_progress: LspProgressMap,
#[cfg(not(windows))]
command_listener: CommandListener,
}
#[cfg(feature = "integration")]
@ -92,6 +104,284 @@ fn setup_integration_logging() {
.apply();
}
#[derive(Clone)]
pub enum SocketResponse {
Empty,
Bytes(Vec<u8>),
Json(serde_json::Value),
}
impl<T: serde::Serialize> From<anyhow::Result<T>> for SocketResponse {
fn from(value: anyhow::Result<T>) -> Self {
SocketResponse::Bytes(serde_json::to_vec(&value.map_err(|s| s.to_string())).unwrap())
}
}
impl From<anyhow::Error> for SocketResponse {
fn from(value: anyhow::Error) -> Self {
Self::from(anyhow::Result::<()>::Err(value))
}
}
type SocketChannelMessageType = (usize, SocketResponse);
impl SocketResponse {
async fn respond(self, index: usize, responder: &Sender<SocketChannelMessageType>) -> () {
let _ = responder.send((index, self)).await;
}
pub fn tag(&self) -> &[u8; 1] {
match self {
SocketResponse::Empty => b"0",
SocketResponse::Bytes { .. } => b"b",
SocketResponse::Json { .. } => b"j",
}
}
pub fn into_bytes(self) -> Vec<u8> {
match self {
SocketResponse::Empty => Vec::new(),
SocketResponse::Bytes(bytes) => bytes,
SocketResponse::Json(json) => json.to_string().into_bytes(),
}
}
}
fn socket_read_buffer(editor: &mut Editor) -> SocketResponse {
let (_view, doc) = current!(editor);
SocketResponse::Bytes(doc.text().slice(..).bytes().collect())
}
fn slice_as_cow_str<'a>(text: RopeSlice<'a>) -> Cow<'a, str> {
match text.as_str() {
Some(x) => Cow::Borrowed(x),
None => Cow::Owned(text.to_string()),
}
}
fn socket_read_selections(editor: &mut Editor) -> SocketResponse {
let (view, doc) = current!(editor);
let text = doc.text().slice(..);
let selection = doc.selection(view.id);
fn sort_range(a: usize, b: usize) -> (usize, usize) {
if a < b {
(a, b)
} else {
(b, a)
}
}
#[derive(serde::Serialize)]
struct Lr {
left: usize,
right: usize,
}
impl Lr {
fn new(a: usize, b: usize) -> Self {
let (left, right) = sort_range(a, b);
Self { left, right }
}
}
#[derive(serde::Serialize)]
struct X<'a> {
text: Cow<'a, str>,
line: usize,
bytes: Lr,
// TODO char left/right?
}
let lines: Vec<X> = selection
.line_ranges(text)
.flat_map(|(left, right)| {
let mut res = Vec::new();
res.push({
let line = left;
let byte_left = text.line_to_byte(line);
let byte_right = text.line_to_byte(line + 1);
X {
text: slice_as_cow_str(text.byte_slice(byte_left..byte_right)),
line,
bytes: Lr::new(byte_left, byte_right),
}
});
for line in (left + 1)..=right {
let byte_left = res.last().unwrap().bytes.right;
let byte_right = text.line_to_byte(line + 1);
res.push(X {
text: slice_as_cow_str(text.byte_slice(byte_left..byte_right)),
line,
bytes: Lr::new(byte_left, byte_right),
});
}
res
})
.collect();
#[derive(serde::Serialize)]
struct AnchorHead {
anchor: usize,
head: usize,
#[serde(flatten)]
lr: Lr,
}
impl AnchorHead {
fn new(anchor: usize, head: usize) -> Self {
Self {
anchor,
head,
lr: Lr::new(anchor, head),
}
}
}
#[derive(serde::Serialize)]
struct Fragment<'a> {
text: Cow<'a, str>,
chars: AnchorHead,
bytes: AnchorHead,
lines: Lr,
}
let ranges: Vec<_> = selection
.iter()
.map(|r| {
let &Range { anchor, head, .. } = r;
let chars = AnchorHead::new(anchor, head);
let bytes = AnchorHead::new(text.char_to_byte(anchor), text.char_to_byte(head));
let lines = Lr::new(
text.char_to_line(chars.lr.left),
text.char_to_line(chars.lr.left.max(chars.lr.right.saturating_sub(1))),
);
let text = r.fragment(text);
Fragment {
text,
chars,
bytes,
lines,
}
})
.collect();
SocketResponse::Json(serde_json::json!({
"primary": selection.primary_index(),
"ranges": ranges,
"lines": lines,
}))
}
pub async fn execute_socket_commands(
ctx: &mut super::commands::Context<'_>,
commands: Vec<MappableCommand>,
responder: &Sender<SocketChannelMessageType>,
) {
let mut selection_stash = vec![];
let mut register_stash = vec![];
for (index, command) in commands.into_iter().enumerate() {
match command.name() {
"socket-read-buffer" => {
socket_read_buffer(ctx.editor)
.respond(index, &responder)
.await;
}
"socket-read-selections" => {
socket_read_selections(ctx.editor)
.respond(index, &responder)
.await;
}
"socket-push-selection" => {
let (view, doc) = current!(ctx.editor);
let selection = doc.selection(view.id);
selection_stash.push(selection.clone())
}
"socket-pop-selection" => {
let res: SocketResponse = match selection_stash.pop() {
Some(selection) => {
let (view, doc) = current!(ctx.editor);
doc.set_selection(view.id, selection);
Ok(())
}
None => Err(anyhow!("No selection in stash")),
}
.into();
res.respond(index, responder).await;
}
"socket-register" => {
if let MappableCommand::Typable { mut args, .. } = command {
let res: SocketResponse = match args.as_mut_slice() {
[action, name, value] if action == "push" && name.len() == 1 => {
let name = name.chars().next().unwrap();
ctx.editor
.registers
.push(name, std::mem::take(value))
.into()
}
[action, name, ..] if action == "write" && name.len() == 1 => {
let name = name.chars().next().unwrap();
ctx.editor
.registers
.write(name, args.drain(2..).collect())
.into()
}
[action, name] if action == "read" && name.len() == 1 => {
let name = name.chars().next().unwrap();
Ok(match ctx.editor.registers.read(name, &ctx.editor) {
None => vec![],
Some(vals) => vals.collect::<Vec<_>>(),
})
.into()
}
[action] if action == "read" => {
let name = ctx
.register
.unwrap_or_else(|| ctx.editor.config.load().default_yank_register);
Ok(match ctx.editor.registers.read(name, &ctx.editor) {
None => vec![],
Some(vals) => vals.collect::<Vec<_>>(),
})
.into()
}
[action, name] if action == "!remove" && name.len() == 1 => {
let name = name.chars().next().unwrap();
Ok(ctx.editor.registers.remove(name)).into()
}
[action] if action == "!clear" => Ok(ctx.register.take()).into(),
[action, name] if action == "!set" && name.len() == 1 => {
let name = name.chars().next().unwrap();
Ok(ctx.register.replace(name)).into()
}
[action, name] if action == "!push" && name.len() == 1 => {
let name = name.chars().next().unwrap();
let old = ctx.register.replace(name);
register_stash.push(old);
Ok(old).into()
}
[action] if action == "!pop" => match register_stash.pop() {
Some(reg) => Ok(std::mem::replace(&mut ctx.register, reg)),
None => Err(anyhow!("No register in stash")),
}
.into(),
args => anyhow!("Invalid register command: {args:?}").into(),
};
res.respond(index, responder).await;
} else {
panic!("?")
}
}
// "socket-set-selection" => {
// ctx.editor.registers.push(name, value)
// socket_read_selections(ctx.editor).respond(&responder).await;
// }
_ => {
command.execute(ctx);
}
}
}
}
impl Application {
pub fn new(args: Args, config: Config, lang_loader: syntax::Loader) -> Result<Self, Error> {
#[cfg(feature = "integration")]
@ -151,7 +441,7 @@ impl Application {
for (file, pos) in files_it {
nr_of_files += 1;
if file.is_dir() {
return Err(anyhow::anyhow!(
return Err(anyhow!(
"expected a path to file, but found a directory: {file:?}. (to open a directory pass it as first argument)"
));
} else {
@ -173,7 +463,7 @@ impl Application {
nr_of_files -= 1;
continue;
}
Err(err) => return Err(anyhow::anyhow!(err)),
Err(err) => return Err(anyhow!(err)),
// We can't open more than 1 buffer for 1 file, in this case we already have opened this file previously
Ok(doc_id) if old_id == Some(doc_id) => {
nr_of_files -= 1;
@ -234,6 +524,13 @@ impl Application {
])
.context("build signal handler")?;
#[cfg(not(windows))]
let command_listener = {
let pid = std::process::id();
let file_path = std::env::temp_dir().join(format!("helix.{pid}.sock"));
CommandListener::new(file_path)
};
let app = Self {
compositor,
terminal,
@ -242,6 +539,7 @@ impl Application {
signals,
jobs: Jobs::new(),
lsp_progress: LspProgressMap::new(),
command_listener,
};
Ok(app)
@ -307,6 +605,19 @@ impl Application {
tokio::select! {
biased;
Some((commands, responder)) = self.command_listener.rx.recv() => {
let mut ctx = super::commands::Context {
register: None,
count: None,
editor: &mut self.editor,
callback: Vec::new(),
on_next_key_callback: None,
jobs: &mut self.jobs,
};
execute_socket_commands(&mut ctx, commands, &responder).await;
self.render().await;
}
Some(signal) = self.signals.next() => {
if !self.handle_signals(signal).await {
return false;
@ -1174,3 +1485,177 @@ impl Application {
errs
}
}
struct CommandListener {
socket_path: PathBuf,
rx: Receiver<(Vec<MappableCommand>, Sender<SocketChannelMessageType>)>,
}
impl Drop for CommandListener {
fn drop(&mut self) {
if let Err(err) = std::fs::remove_file(&self.socket_path) {
log::error!(
"Error removing command socket {}: {err}",
self.socket_path.display()
);
}
}
}
impl CommandListener {
pub fn new(socket_path: PathBuf) -> Self {
let rx = spawn_command_listener(socket_path.clone());
Self { rx, socket_path }
}
}
async fn read_string(len: usize, buf: &mut std::io::Cursor<Vec<u8>>) -> anyhow::Result<String> {
let mut res = Vec::with_capacity(len);
AsyncReadExt::read_buf(buf, &mut res).await?;
Ok(String::from_utf8(res)?)
}
async fn read_u16_le_string(buf: &mut std::io::Cursor<Vec<u8>>) -> anyhow::Result<String> {
read_string(buf.read_u16_le().await? as usize, buf).await
}
async fn parse_mappable_command(
buf: &mut std::io::Cursor<Vec<u8>>,
) -> anyhow::Result<MappableCommand> {
match buf.read_u8().await? {
b':' => {
let name = read_u16_le_string(buf).await?;
let arg_count = buf.read_u16_le().await? as usize;
let mut args = vec![];
for _ in 0..arg_count {
args.push(read_u16_le_string(buf).await?);
}
Ok(MappableCommand::Typable {
name,
args,
doc: String::new(),
})
}
b'@' => {
let command = read_u16_le_string(buf).await?;
helix_view::input::parse_macro(&command).map(|keys| MappableCommand::Macro {
name: command,
keys,
})
}
b'!' => {
static SORTED_STATIC_COMMANDS: Lazy<Vec<MappableCommand>> = Lazy::new(|| {
let mut res = MappableCommand::STATIC_COMMAND_LIST.to_vec();
res.sort_by(|a, b| a.name().cmp(&b.name()));
res
});
let name = read_u16_le_string(buf).await?;
SORTED_STATIC_COMMANDS
.binary_search_by_key(&name.as_str(), |a| a.name())
.ok()
.map(|x| SORTED_STATIC_COMMANDS[x].clone())
.ok_or_else(|| anyhow!("No command named {name:?}"))
}
code => {
anyhow::bail!("Invalid command kind: {}", code as char)
}
}
}
fn spawn_command_listener(
socket_path: PathBuf,
) -> Receiver<(Vec<MappableCommand>, Sender<SocketChannelMessageType>)> {
let (tx, rx) = tokio::sync::mpsc::channel(100);
tokio::spawn(async move {
if let Ok(listener) = tokio::net::UnixListener::bind(&socket_path) {
// 'accept_clients:
loop {
match listener.accept().await {
Ok((stream, addr)) => {
info!("Got a connection at {addr:?}");
let tx = tx.clone();
let _handle: tokio::task::JoinHandle<anyhow::Result<()>> =
tokio::spawn(async move {
let (mut read, mut write) = stream.into_split();
// 'read_commands:
loop {
let (response_tx, mut response_rx) =
tokio::sync::mpsc::channel::<SocketChannelMessageType>(100);
let result = (async {
let total_message_length =
read.read_u16_le().await? as usize;
let commands = {
let mut buf = vec![0u8; total_message_length];
read.read_exact(&mut buf).await?;
let mut cursor = std::io::Cursor::new(buf);
let count = cursor.read_u16_le().await? as usize;
let mut commands = vec![];
for _ in 0..count {
commands.push(
parse_mappable_command(&mut cursor).await?,
);
}
commands
};
info!("Got a command from {addr:?}: {commands:?}");
let count = commands.len();
tx.send((commands, response_tx)).await?;
anyhow::Ok(count as u16)
})
.await;
match result {
Ok(count) => {
write.write(b"o").await?;
write.write_u16_le(count).await?;
let mut responses =
vec![SocketResponse::Empty; count as usize];
while let Some((i, response)) = response_rx.recv().await
{
responses[i] = response;
}
for response in responses.into_iter() {
write.write(response.tag()).await?;
let bytes = response.into_bytes();
debug!("Sending response of {} bytes", bytes.len());
write.write_u32_le(bytes.len() as u32).await?;
write.write(&bytes).await?;
}
}
Err(err) => {
write.write(b"e").await?;
let errs = err.to_string();
let sliced = truncate_str_to_u16(errs.as_str());
write.write_u16_le(sliced.len() as u16).await?;
write.write(sliced.as_bytes()).await?;
}
}
}
});
}
Err(err) => {
error!("Failed to accept listener {err:?}");
}
}
}
}
});
rx
}
fn truncate_str_to_u16(sliced: &str) -> &str {
if sliced.len() > u16::MAX as usize {
for i in 0..sliced.len() {
if let Some((head, _tail)) = sliced.split_at_checked(sliced.len() - i) {
return head;
};
}
// This should be unreachable, but it would be odd to panic here.
error!(
"Somehow we sliced a string down to nothing...? Is none of it valid utf8?: {sliced:?}"
);
sliced
} else {
sliced
}
}

View File

@ -211,7 +211,7 @@ use helix_view::{align_view, Align};
pub enum MappableCommand {
Typable {
name: String,
args: String,
args: Vec<String>,
doc: String,
},
Static {
@ -252,9 +252,12 @@ impl MappableCommand {
jobs: cx.jobs,
scroll: None,
};
if let Err(e) =
typed::execute_command(&mut cx, command, args, PromptEvent::Validate)
{
if let Err(e) = typed::execute_command(
&mut cx,
command,
&args.join(" "),
PromptEvent::Validate,
) {
cx.editor.set_error(format!("{}", e));
}
} else {
@ -282,11 +285,11 @@ impl MappableCommand {
}
}
pub fn name(&self) -> &str {
match &self {
Self::Typable { name, .. } => name,
pub fn name<'a>(&'a self) -> &'a str {
match self {
Self::Typable { name, .. } => name.as_str(),
Self::Static { name, .. } => name,
Self::Macro { name, .. } => name,
Self::Macro { name, .. } => name.as_str(),
}
}
@ -614,6 +617,37 @@ impl MappableCommand {
);
}
/* const _: () = {
let mut i = 1usize;
let arr = MappableCommand::STATIC_COMMAND_LIST;
loop {
if i >= arr.len() {
break;
}
match (&arr[i - 1], &arr[i]) {
(MappableCommand::Static { name: a, .. }, MappableCommand::Static { name: b, .. }) => {
// assert!(a.len() <= b.len(), "Unsorted static command list");
let n = if a.len() < b.len() { a.len() } else { b.len() };
let mut j = 0;
loop {
if j >= n {
break;
}
assert!(
a.as_bytes()[j] <= b.as_bytes()[j],
"Unsorted static command list"
);
j += 1;
}
}
_ => {
panic!("nonstatic in static command list")
}
}
i += 1;
}
}; */
impl fmt::Debug for MappableCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@ -658,7 +692,7 @@ impl std::str::FromStr for MappableCommand {
MappableCommand::Typable {
name: cmd.name.to_owned(),
doc,
args: args.to_string(),
args: vec![args.to_string()],
}
})
.ok_or_else(|| anyhow!("No TypableCommand named '{}'", s))
@ -3411,7 +3445,7 @@ pub fn command_palette(cx: &mut Context) {
.iter()
.map(|cmd| MappableCommand::Typable {
name: cmd.name.to_owned(),
args: String::new(),
args: Vec::new(),
doc: cmd.doc.to_owned(),
}),
);
@ -6227,7 +6261,12 @@ fn shell_keep_pipe(cx: &mut Context) {
for (i, range) in selection.ranges().iter().enumerate() {
let fragment = range.slice(text);
if let Err(err) = shell_impl(shell, input, Some(fragment.into())) {
if let Err(err) = shell_impl(
shell,
input,
Some(fragment.into()),
doc.path().map(|x| x.as_path()),
) {
log::debug!("Shell command failed: {}", err);
} else {
ranges.push(*range);
@ -6248,14 +6287,22 @@ fn shell_keep_pipe(cx: &mut Context) {
);
}
fn shell_impl(shell: &[String], cmd: &str, input: Option<Rope>) -> anyhow::Result<Tendril> {
tokio::task::block_in_place(|| helix_lsp::block_on(shell_impl_async(shell, cmd, input)))
fn shell_impl(
shell: &[String],
cmd: &str,
input: Option<Rope>,
file_path: Option<&Path>,
) -> anyhow::Result<Tendril> {
tokio::task::block_in_place(|| {
helix_lsp::block_on(shell_impl_async(shell, cmd, input, file_path))
})
}
async fn shell_impl_async(
shell: &[String],
cmd: &str,
input: Option<Rope>,
file_path: Option<&Path>,
) -> anyhow::Result<Tendril> {
use std::process::Stdio;
use tokio::process::Command;
@ -6274,6 +6321,19 @@ async fn shell_impl_async(
process.stdin(Stdio::null());
}
if let Some(file_path) = file_path {
process.env("HELIX_FILE_PATH", file_path);
}
{
// TODO get this as an arg.
let command_socket_path = {
let pid = std::process::id();
std::env::temp_dir().join(format!("helix.{pid}.sock"))
};
process.env("HELIX_SOCKET_PATH", command_socket_path);
}
let mut process = match process.spawn() {
Ok(process) => process,
Err(e) => {
@ -6341,7 +6401,12 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) {
output.clone()
} else {
let input = range.slice(text);
match shell_impl(shell, cmd, pipe.then(|| input.into())) {
match shell_impl(
shell,
cmd,
pipe.then(|| input.into()),
doc.path().map(|x| x.as_path()),
) {
Ok(mut output) => {
if !input.ends_with("\n") && output.ends_with('\n') {
output.pop();

View File

@ -2393,13 +2393,20 @@ fn run_shell_command(
let shell = cx.editor.config().shell.clone();
let args = args.join(" ");
let (_view, doc) = current!(cx.editor);
let current_file_path = doc.path().cloned();
let callback = async move {
let output = shell_impl_async(&shell, &args, None).await?;
let path = current_file_path.as_ref().map(|x| x.as_path());
let output = shell_impl_async(&shell, &args, None, path).await?;
let call: job::Callback = Callback::EditorCompositor(Box::new(
move |editor: &mut Editor, compositor: &mut Compositor| {
if !output.is_empty() {
let contents = ui::Markdown::new(
format!("```sh\n{}\n```", output.trim_end()),
if output.starts_with("```") {
output.trim_end().to_string()
} else {
format!("```sh\n{}\n```", output.trim_end())
},
editor.syn_loader.clone(),
);
let popup = Popup::new("shell", contents).position(Some(
@ -2407,7 +2414,7 @@ fn run_shell_command(
));
compositor.replace_or_push("shell", popup);
}
editor.set_status("Command run");
// editor.set_status("Command run");
},
));
Ok(call)