Open files through TUI-WM: context-menu Open, double-click, editor fallback

- Double-clicking a file or choosing the new 'Öppna' context-menu item
  asks TUI-WM to open it via $TUI_WM_SOCKET (open_file), inheriting
  the user's file associations and default editor
- Outside TUI-WM the configured text_editor is used instead: the TUI
  suspends, runs the editor on the same terminal and resumes
- Directories keep navigating as before

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 20:33:08 +02:00
parent 5d15cde675
commit f0bb473e09
6 changed files with 137 additions and 4 deletions

Binary file not shown.

View File

@@ -24,6 +24,8 @@ pub struct ClipboardEntry {
#[derive(Debug, Clone, PartialEq)]
pub enum ContextMenuItem {
/// Öppna filen (via TUI-WM om möjligt) eller gå in i katalogen
Open,
Copy,
Cut,
Paste,
@@ -112,6 +114,9 @@ pub struct App {
/// terminalen, vilket gör hit-testing korrekt även i nästlade
/// terminaler (t.ex. inne i TUI-WM).
pub term_size: (u16, u16),
/// Editor-kommando + fil som ska köras genom att TUI:t tillfälligt
/// suspenderas (fallback när TUI-WM inte kör). Hanteras i run_app.
pub pending_spawn: Option<(String, PathBuf)>,
}
// Which field inside the settings editor is being edited
@@ -187,6 +192,31 @@ impl App {
settings_hover_button: None,
unicode_support: detect_unicode_support(),
term_size: crossterm::terminal::size().unwrap_or((80, 24)),
pending_spawn: None,
}
}
/// Öppna en fil: via TUI-WM:s socket om vi kör i ett TUI-WM-fönster,
/// annars med den konfigurerade editorn (TUI:t suspenderas under tiden).
pub fn open_path(&mut self, path: &PathBuf) {
if path.is_dir() {
self.navigate_to(path.clone());
return;
}
if crate::tui_wm::available() {
match crate::tui_wm::open_file(path) {
Ok(()) => self.set_status(format!("Öppnar {} via TUI-WM", path.display())),
Err(e) => self.set_status(format!("TUI-WM-öppning misslyckades: {}", e)),
}
return;
}
let editor = self.config.text_editor.trim().to_string();
if editor.is_empty() {
self.set_status(
"Ingen editor konfigurerad (kugghjulet ≡) och TUI-WM kör inte".to_string(),
);
} else {
self.pending_spawn = Some((editor, path.clone()));
}
}
@@ -449,6 +479,7 @@ impl App {
}
} else {
if has_selection {
items.push(ContextMenuItem::Open);
items.push(ContextMenuItem::Copy);
items.push(ContextMenuItem::Cut);
}

View File

@@ -12,6 +12,30 @@ use crate::ui;
pub fn run_app<B: Backend<Error = io::Error>>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<()> {
loop {
// Fallback-öppning utanför TUI-WM: suspendera TUI:t, kör editorn
// på samma terminal, återuppta och rita om.
if let Some((editor, path)) = app.pending_spawn.take() {
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
disable_raw_mode()?;
crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
let status = std::process::Command::new("sh")
.arg("-c")
.arg(format!("{} '{}'", editor, path.display().to_string().replace('\'', r"'\''")))
.status();
enable_raw_mode()?;
crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
terminal.clear()?;
match status {
Ok(s) if s.success() => {}
Ok(s) => app.set_status(format!("Editorn avslutades med {}", s)),
Err(e) => app.set_status(format!("Kunde inte starta editorn: {}", e)),
}
app.refresh();
}
terminal.draw(|f| ui::draw(f, app))?;
if event::poll(Duration::from_millis(50))? {
@@ -381,6 +405,12 @@ fn execute_context_menu(app: &mut App) {
app.context_menu = None;
if let Some(item) = item {
match item {
ContextMenuItem::Open => {
if let Some(&i) = app.selected_indices.first()
&& let Some(e) = app.entries.get(i) {
app.open_path(&e.path.clone());
}
}
ContextMenuItem::Copy => app.copy_selected(),
ContextMenuItem::Cut => app.cut_selected(),
ContextMenuItem::Paste => {
@@ -875,10 +905,10 @@ fn handle_left_click(app: &mut App, col: u16, row: u16) {
if is_double {
app.last_click_entry = None;
if let Some(e) = app.entries.get(idx).cloned()
&& e.is_dir() {
app.navigate_to(e.path.clone());
}
if let Some(e) = app.entries.get(idx).cloned() {
// Kataloger navigeras; filer öppnas via TUI-WM/editor
app.open_path(&e.path.clone());
}
} else {
app.select_only(idx);
}

View File

@@ -2,6 +2,7 @@ mod app;
mod config;
mod file_ops;
mod theme;
mod tui_wm;
mod ui;
mod events;

69
src/tui_wm.rs Normal file
View File

@@ -0,0 +1,69 @@
//! Integration med TUI-WM:s socket-API.
//!
//! När TUI-FM körs i ett TUI-WM-fönster sätts miljövariabeln
//! `TUI_WM_SOCKET`. Via den kan filer öppnas i egna fönster med
//! användarens filassociationer/standard-editor (TUI-WM visar en
//! "Öppna med"-dialog för okända format).
use std::path::Path;
/// Kör TUI-FM inne i TUI-WM?
pub fn available() -> bool {
std::env::var("TUI_WM_SOCKET").is_ok()
}
/// Be TUI-WM öppna en fil. Blockerar bara för handskakningen.
pub fn open_file(path: &Path) -> Result<(), String> {
let sock = std::env::var("TUI_WM_SOCKET").map_err(|_| "TUI_WM_SOCKET saknas".to_string())?;
open_via_socket(&sock, path)
}
#[cfg(unix)]
fn open_via_socket(sock: &str, path: &Path) -> Result<(), String> {
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
let mut stream = UnixStream::connect(sock).map_err(|e| format!("anslutning: {}", e))?;
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.ok();
let write_msg = |s: &mut UnixStream, v: serde_json::Value| -> Result<(), String> {
let data = serde_json::to_vec(&v).map_err(|e| e.to_string())?;
s.write_all(&(data.len() as u32).to_be_bytes())
.and_then(|_| s.write_all(&data))
.map_err(|e| e.to_string())
};
let read_msg = |s: &mut UnixStream| -> Result<serde_json::Value, String> {
let mut len = [0u8; 4];
s.read_exact(&mut len).map_err(|e| e.to_string())?;
let mut buf = vec![0u8; u32::from_be_bytes(len) as usize];
s.read_exact(&mut buf).map_err(|e| e.to_string())?;
serde_json::from_slice(&buf).map_err(|e| e.to_string())
};
write_msg(
&mut stream,
serde_json::json!({"type": "hello", "role": "app", "width": 0, "height": 0}),
)?;
let hello = read_msg(&mut stream)?;
if hello.get("type").and_then(|t| t.as_str()) != Some("hello_ok") {
return Err("oväntat svar från TUI-WM".to_string());
}
write_msg(
&mut stream,
serde_json::json!({
"type": "open_file",
"path": path.to_string_lossy(),
"request_id": "tui-fm-open",
}),
)?;
// Vänta in kvittensen så meddelandet hinner behandlas
let _ = read_msg(&mut stream);
Ok(())
}
#[cfg(not(unix))]
fn open_via_socket(_sock: &str, _path: &Path) -> Result<(), String> {
Err("socket-öppning stöds inte på denna plattform ännu".to_string())
}

View File

@@ -533,6 +533,8 @@ fn draw_context_menu(f: &mut Frame, app: &App, menu: crate::app::ContextMenu) {
// Use 2-char ASCII icon codes — emoji (U+1F000+) are 2-wide in terminals and
// cause ghost characters and column drift when partially overwritten.
let (icon, label, item_style): (&str, &str, Style) = match item {
ContextMenuItem::Open =>
("O ", "Öppna", Style::default().add_modifier(Modifier::BOLD)),
ContextMenuItem::Copy =>
("C ", "Kopiera", Style::default()),
ContextMenuItem::Cut =>