start work on tui app and rework secrets system

This commit is contained in:
2026-02-15 01:12:38 +04:00
parent a89275f163
commit 2a8a30fc14
12 changed files with 2031 additions and 103 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/target /target
/.direnv /.direnv
/result /result
/src-old

1256
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "sops-tui"
version = "0.1.0"
edition = "2021"
[dependencies]
ratatui = { version = "0.29", features = ["crossterm"] }
crossterm = { version = "0.28", features = ["event-stream"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
clap = { version = "4", features = ["derive"] }
unicode-width = "0.2"
futures = "0.3"
color-eyre = "0.6.5"
ratatui-form = "0.1.1"

39
flake.lock generated
View File

@@ -1,5 +1,20 @@
{ {
"nodes": { "nodes": {
"crane": {
"locked": {
"lastModified": 1770169865,
"narHash": "sha256-iPiy13xzDQ9GjpOez+NNIjh/qjl7i4RDf9dF2x5mF9I=",
"owner": "ipetkov",
"repo": "crane",
"rev": "8254ccf3b5b5131890ee073776f2e61c6d1e55d4",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-parts": { "flake-parts": {
"inputs": { "inputs": {
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
@@ -51,8 +66,30 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1770347142,
"narHash": "sha256-uz+ZSqXpXEPtdRPYwvgsum/CfNq7AUQ/0gZHqTigiPM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "2859683cd9ef7858d324c5399b0d8d6652bf4044",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
} }
} }
}, },

161
flake.nix
View File

@@ -4,86 +4,87 @@
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
crane.url = "github:ipetkov/crane";
# crane.url = "github:ipetkov/crane"; rust-overlay = {
# flake-utils.url = "github:numtide/flake-utils"; url = "github:oxalica/rust-overlay";
# rust-overlay = { inputs.nixpkgs.follows = "nixpkgs";
# url = "github:oxalica/rust-overlay";
# inputs.nixpkgs.follows = "nixpkgs";
# };
};
outputs =
{ flake-parts, ... }@inputs:
flake-parts.lib.mkFlake { inherit inputs; } {
flake.flakeModules.default = ./nix/flakeModule.nix;
}; };
};
# outputs = outputs =
# { {
# nixpkgs, flake-parts,
# crane, crane,
# flake-utils, rust-overlay,
# rust-overlay, ...
# ... }@inputs:
# }: flake-parts.lib.mkFlake { inherit inputs; } {
# flake-utils.lib.eachDefaultSystem ( systems = [
# system: "x86_64-linux"
# let "aarch64-linux"
# pkgs = import nixpkgs { "x86_64-darwin"
# inherit system; "aarch64-darwin"
# overlays = [ ];
# (import rust-overlay) flake.flakeModules.default = ./nix/flakeModule.nix;
# ]; perSystem =
# }; {
# inherit (pkgs) lib; pkgs,
# lib,
# craneLib = (crane.mkLib pkgs).overrideToolchain ( system,
# p: ...
# p.rust-bin.nightly.latest.default.override { }:
# extensions = [ let
# "rustc-codegen-cranelift-preview" craneLib = (crane.mkLib pkgs).overrideToolchain (
# "rust-analyzer" p:
# "rust-src" p.rust-bin.nightly.latest.default.override {
# ]; extensions = [
# } "rustc-codegen-cranelift-preview"
# ); "rust-analyzer"
# src = craneLib.cleanCargoSource ./.; "rust-src"
# commonArgs = { ];
# inherit src; }
# strictDeps = true; );
# }; src = craneLib.cleanCargoSource ./.;
# cargoArtifacts = craneLib.buildDepsOnly commonArgs; commonArgs = {
# individualCrateArgs = commonArgs // { inherit src;
# inherit cargoArtifacts; strictDeps = true;
# inherit (craneLib.crateNameFromCargoToml { inherit src; }) version; };
# }; cargoArtifacts = craneLib.buildDepsOnly commonArgs;
# individualCrateArgs = commonArgs // {
# fileSetForCrate = inherit cargoArtifacts;
# crate: inherit (craneLib.crateNameFromCargoToml { inherit src; }) version;
# lib.fileset.toSource { };
# root = ./.;
# fileset = lib.fileset.unions [ fileSetForCrate =
# ./Cargo.toml crate:
# ./Cargo.lock lib.fileset.toSource {
# ]; root = ./.;
# }; fileset = lib.fileset.unions [
# server = craneLib.buildPackage ( ./Cargo.toml
# individualCrateArgs ./Cargo.lock
# // { ];
# } };
# ); server = craneLib.buildPackage (
# in individualCrateArgs
# { // {
# devShells.default = craneLib.devShell { }
# packages = with pkgs; [ );
# mold in
# llvmPackages.clang {
# llvmPackages.lld _module.args.pkgs = import inputs.nixpkgs {
# sea-orm-cli inherit system;
# watchexec overlays = [ inputs.rust-overlay.overlays.default ];
# pnpm };
# ]; devShells.default = craneLib.devShell {
# }; packages = with pkgs; [
# } mold
# ); llvmPackages.clang
llvmPackages.lld
sea-orm-cli
watchexec
pnpm
];
};
};
};
} }

View File

@@ -63,7 +63,7 @@ in
secret_map = lib.mapAttrs ( secret_map = lib.mapAttrs (
name: value: name: value:
let let
keys = lib.unique ( sopskeys = lib.unique (
lib.flatten (map (k: per_host_keys.${k}) value.hosts) lib.flatten (map (k: per_host_keys.${k}) value.hosts)
++ (lib.optionals value.global all_keys) ++ (lib.optionals value.global all_keys)
++ cfg.masterKeys ++ cfg.masterKeys
@@ -76,35 +76,71 @@ in
hosts hosts
global global
; ;
inherit keys; keys = value.keys or [ ];
inherit sopskeys;
} }
) secrets; ) secrets;
rules = lib.mapAttrsToList (name: value: { rules = lib.mapAttrsToList (name: value: {
path_regex = "${cfg.secretsDir}/${name}$"; path_regex = "${cfg.secretsDir}/${name}$";
key_groups = [ { age = value.keys; } ]; key_groups = [ { age = value.sopskeys; } ];
}) secret_map; }) secret_map;
sops_secrets_map = lib.concatMapAttrs ( sops_secrets_map =
name: value: host:
let lib.mkMerge (
hasHost = (lib.elem "wawa" value.hosts) || value.global; lib.mapAttrsToList (
in name: value:
if hasHost then let
{ hasHost = (lib.elem host value.hosts) || value.global;
${name} = { isYamlOrJson = value.format == "yaml" || value.format == "json";
inherit (value) format neededForUsers; in
sopsFile = inputs.self + "/${cfg.secretsDir}/${name}"; (
}; (
} [
else (
{ } if hasHost && !(isYamlOrJson && value.keys != [ ]) then
) secret_map; {
${name} = {
inherit (value) format neededForUsers;
sopsFile = inputs.self + "/${cfg.secretsDir}/${name}";
};
}
else
{ }
)
]
++ (lib.map (v: {
"${name}-${v}" = {
inherit (value) format neededForUsers;
sopsFile = inputs.self + "/${cfg.secretsDir}/${name}";
key = v;
};
}) value.keys)
)
)
) secret_map
);
in in
{ {
secrets.nixosModule = flake.secretsManifest = {
{ ... }: secretsDir = cfg.secretsDir;
{ masterKeys = cfg.masterKeys;
config.sops.secrets = sops_secrets_map; hosts = lib.mapAttrs (_: keys: { inherit keys; }) per_host_keys;
secrets = secret_map;
secrets_map = sops_secrets_map;
};
secrets.nixosModule = {
default = host: {
config = {
sops.secrets = sops_secrets_map host;
};
}; };
network =
{ config, ... }:
{
config.sops.secrets = sops_secrets_map config.networking.hostName;
};
};
perSystem = perSystem =
{ pkgs, self', ... }: { pkgs, self', ... }:
let let

128
src/app.rs Normal file
View File

@@ -0,0 +1,128 @@
use color_eyre::Report;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{prelude::CrosstermBackend, widgets::ListState, DefaultTerminal, Terminal};
use ratatui_form::{Email, Form};
use std::{io, path::Path};
use crate::{
event::{AppEvent, Event, EventHandler},
form::FormState,
manifest::{load_manifest, Manifest},
};
// #[derive(Debug)]
pub struct App {
pub running: bool,
pub list_state: ListState,
pub events: EventHandler,
pub isloadingmanifest: bool,
pub manifest: Option<Manifest>,
}
impl App {
pub fn new() -> Self {
Self {
running: true,
list_state: ListState::default().with_selected(Some(0)),
events: EventHandler::new(),
isloadingmanifest: false,
manifest: None,
}
}
pub async fn run(
mut self,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> color_eyre::Result<()> {
let project_root = Path::new("/home/nikkuss/dotfiles-new");
self.events.send(AppEvent::LoadManifest);
while self.running {
terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
match self.events.next().await? {
Event::Tick => self.tick(),
Event::Crossterm(event) => match event {
crossterm::event::Event::Key(key_event)
if key_event.kind == crossterm::event::KeyEventKind::Press =>
{
self.handle_key_events(key_event)?
}
_ => {}
},
Event::App(app_event) => match app_event {
AppEvent::LoadManifest => {
if !self.isloadingmanifest {
let tx = self.events.clone_sender();
tokio::spawn(async move {
let result = load_manifest(&project_root).await;
let _ = tx.send(Event::App(AppEvent::ManifestLoaded(result)));
});
}
}
AppEvent::ManifestLoaded(manifest) => {
self.isloadingmanifest = false;
match manifest {
Ok(m) => {
self.manifest = Some(m);
}
Err(e) => {
self.display_error(e);
}
}
}
AppEvent::SelectionUp => self.selection_up(),
AppEvent::SelectionDown => self.selection_down(),
AppEvent::Quit => self.quit(),
},
}
}
Ok(())
}
/// Handles the key events and updates the state of [`App`].
pub fn handle_key_events(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> {
match key_event.code {
KeyCode::Esc | KeyCode::Char('q') => self.events.send(AppEvent::Quit),
KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => {
self.events.send(AppEvent::Quit)
}
KeyCode::Up => self.events.send(AppEvent::SelectionUp),
KeyCode::Down => self.events.send(AppEvent::SelectionDown),
KeyCode::Char('a') => self.create_secret(),
// Other handlers you could add here.
_ => {}
}
Ok(())
}
pub fn selected_secret_name(&self) -> Option<String> {
let selected = self.list_state.selected()?;
if let Some(manifest) = &self.manifest {
return manifest.secrets.keys().nth(selected).cloned();
}
None
}
/// Handles the tick event of the terminal.
///
/// The tick event is where you can update the state of your application with any logic that
/// needs to be updated at a fixed frame rate. E.g. polling a server, updating an animation.
pub fn tick(&self) {}
/// Set running to false to quit the application.
pub fn quit(&mut self) {
self.running = false;
}
pub fn selection_down(&mut self) {
self.list_state.select_next();
}
pub fn selection_up(&mut self) {
self.list_state.select_previous();
}
pub fn display_error(&mut self, display_error: Report) {
// somehow display the error?
}
pub fn create_secret(&mut self) {}
}

125
src/event.rs Normal file
View File

@@ -0,0 +1,125 @@
use color_eyre::eyre::OptionExt;
use color_eyre::Result;
use futures::{FutureExt, StreamExt};
use ratatui::crossterm::event::Event as CrosstermEvent;
use std::time::Duration;
use tokio::sync::mpsc::{self, UnboundedSender};
use crate::manifest::Manifest;
/// The frequency at which tick events are emitted.
const TICK_FPS: f64 = 30.0;
/// Representation of all possible events.
#[derive(Debug)]
pub enum Event {
Tick,
Crossterm(CrosstermEvent),
App(AppEvent),
}
/// Application events.
///
/// You can extend this enum with your own custom events.
#[derive(Debug)]
pub enum AppEvent {
SelectionUp,
SelectionDown,
LoadManifest,
ManifestLoaded(Result<Manifest>),
/// Quit the application.
Quit,
}
/// Terminal event handler.
#[derive(Debug)]
pub struct EventHandler {
/// Event sender channel.
sender: mpsc::UnboundedSender<Event>,
/// Event receiver channel.
receiver: mpsc::UnboundedReceiver<Event>,
}
impl EventHandler {
/// Constructs a new instance of [`EventHandler`] and spawns a new thread to handle events.
pub fn new() -> Self {
let (sender, receiver) = mpsc::unbounded_channel();
let actor = EventTask::new(sender.clone());
tokio::spawn(async { actor.run().await });
Self { sender, receiver }
}
/// Receives an event from the sender.
///
/// This function blocks until an event is received.
///
/// # Errors
///
/// This function returns an error if the sender channel is disconnected. This can happen if an
/// error occurs in the event thread. In practice, this should not happen unless there is a
/// problem with the underlying terminal.
pub async fn next(&mut self) -> color_eyre::Result<Event> {
self.receiver
.recv()
.await
.ok_or_eyre("Failed to receive event")
}
/// Queue an app event to be sent to the event receiver.
///
/// This is useful for sending events to the event handler which will be processed by the next
/// iteration of the application's event loop.
pub fn send(&mut self, app_event: AppEvent) {
// Ignore the result as the reciever cannot be dropped while this struct still has a
// reference to it
let _ = self.sender.send(Event::App(app_event));
}
pub fn clone_sender(&self) -> UnboundedSender<Event> {
self.sender.clone()
}
}
/// A thread that handles reading crossterm events and emitting tick events on a regular schedule.
struct EventTask {
/// Event sender channel.
sender: mpsc::UnboundedSender<Event>,
}
impl EventTask {
/// Constructs a new instance of [`EventThread`].
fn new(sender: mpsc::UnboundedSender<Event>) -> Self {
Self { sender }
}
/// Runs the event thread.
///
/// This function emits tick events at a fixed rate and polls for crossterm events in between.
async fn run(self) -> color_eyre::Result<()> {
let tick_rate = Duration::from_secs_f64(1.0 / TICK_FPS);
let mut reader = crossterm::event::EventStream::new();
let mut tick = tokio::time::interval(tick_rate);
loop {
let tick_delay = tick.tick();
let crossterm_event = reader.next().fuse();
tokio::select! {
_ = self.sender.closed() => {
break;
}
_ = tick_delay => {
self.send(Event::Tick);
}
Some(Ok(evt)) = crossterm_event => {
self.send(Event::Crossterm(evt));
}
};
}
Ok(())
}
/// Sends an event to the receiver.
fn send(&self, event: Event) {
// Ignores the result because shutting down the app drops the receiver, which causes the send
// operation to fail. This is expected behavior and should not panic.
let _ = self.sender.send(event);
}
}

1
src/form.rs Normal file
View File

@@ -0,0 +1 @@
pub struct FormState {}

41
src/main.rs Normal file
View File

@@ -0,0 +1,41 @@
use std::{io, path::Path};
use color_eyre::Result;
use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{prelude::CrosstermBackend, Terminal};
use crate::app::App;
mod app;
mod event;
mod form;
mod manifest;
mod ui;
#[tokio::main]
async fn main() -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Set up panic hook to restore terminal
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(panic);
}));
// Run the app
let result = App::new().run(&mut terminal).await;
// Restore terminal
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
result
}

79
src/manifest.rs Normal file
View File

@@ -0,0 +1,79 @@
use color_eyre::{eyre::eyre, Result};
use serde::Deserialize;
use std::{collections::BTreeMap, fmt, path::Path};
use tokio::process::Command;
/// Top-level manifest from `nix eval .#secretsManifest --json`
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
#[serde(rename = "secretsDir")]
pub secrets_dir: String,
#[serde(rename = "masterKeys")]
pub master_keys: Vec<String>,
pub hosts: BTreeMap<String, HostInfo>,
pub secrets: BTreeMap<String, SecretInfo>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HostInfo {
pub keys: Vec<String>,
}
/// Per-secret info, pre-computed by Nix
#[derive(Debug, Clone, Deserialize)]
pub struct SecretInfo {
pub format: SecretFormat,
pub global: bool,
pub hosts: Vec<String>,
#[serde(rename = "neededForUsers")]
pub needed_for_users: bool,
pub keys: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SecretFormat {
Json,
Yaml,
Dotenv,
Ini,
Binary,
}
impl fmt::Display for SecretFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SecretFormat::Json => write!(f, "json"),
SecretFormat::Yaml => write!(f, "yaml"),
SecretFormat::Dotenv => write!(f, "dotenv"),
SecretFormat::Ini => write!(f, "ini"),
SecretFormat::Binary => write!(f, "binary"),
}
}
}
/// Load manifest by running `nix eval .#secretsManifest --json`
pub async fn load_manifest(path: &Path) -> Result<Manifest> {
let path = path.to_string_lossy();
let timeout_duration = std::time::Duration::from_secs(30);
let result = tokio::time::timeout(timeout_duration, async {
Command::new("nix")
.args(["eval", &format!("{path}#secretsManifest"), "--json"])
.output()
.await
})
.await;
match result {
Ok(Ok(output)) => {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(eyre!("nix eval failed:\n{}", stderr))
} else {
serde_json::from_slice(&output.stdout).map_err(Into::into)
}
}
Ok(Err(e)) => Err(eyre!("Failed to run nix eval: {}", e)),
Err(_) => Err(eyre!("nix eval timed out after 30s")),
}
}

206
src/ui.rs Normal file
View File

@@ -0,0 +1,206 @@
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, List, Paragraph, StatefulWidget, Widget},
};
use crate::app::App;
impl Widget for &mut App {
/// Renders the user interface widgets.
///
// This is where you add new widgets.
// See the following resources:
// - https://docs.rs/ratatui/latest/ratatui/widgets/index.html
// - https://github.com/ratatui/ratatui/tree/master/examples
fn render(self, area: Rect, buf: &mut Buffer) {
let items = if let Some(manifest) = &self.manifest {
manifest
.secrets
.keys()
.map(|v| Text::from(truncate_with_ellipsis(v, 50)))
.collect()
} else {
Vec::new()
};
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
.split(area);
let left = layout[0];
let right = layout[1];
let left_block = Block::default().title_bottom("meow").borders(Borders::ALL);
let list = List::default()
.block(left_block)
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White))
.highlight_symbol("\u{25b6} ")
.items(items);
StatefulWidget::render(list, left, buf, &mut self.list_state);
render_secret_detail(self, right, buf);
}
}
fn render_secret_detail(state: &App, area: Rect, buf: &mut Buffer) {
let manifest = match &state.manifest {
Some(m) => m,
None => {
render_placeholder(area, buf, " Detail ", "No manifest loaded");
return;
}
};
let selected_name = match state.selected_secret_name() {
Some(name) => name.to_string(),
None => {
render_placeholder(area, buf, " Detail ", "No secret selected");
return;
}
};
let secret = match manifest.secrets.get(&selected_name) {
Some(s) => s,
None => return,
};
let title = format!(" {} ", selected_name);
let block = Block::default().title(title).borders(Borders::ALL);
// Build detail lines
let mut lines: Vec<Line> = Vec::new();
// Separator
lines.push(Line::from(""));
// Format
lines.push(Line::from(vec![
Span::styled(" Format: ", Style::default().fg(Color::DarkGray)),
Span::styled(secret.format.to_string(), Style::default().fg(Color::White)),
]));
// Scope
let scope = if secret.global {
"global".to_string()
} else {
"host-specific".to_string()
};
lines.push(Line::from(vec![
Span::styled(" Scope: ", Style::default().fg(Color::DarkGray)),
Span::styled(scope, Style::default().fg(Color::White)),
]));
if !secret.global {
// Hosts
let hosts_str = if secret.hosts.is_empty() {
if secret.global {
manifest
.hosts
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
} else {
"none".to_string()
}
} else {
secret.hosts.join(", ")
};
lines.push(Line::from(vec![
Span::styled(" Hosts: ", Style::default().fg(Color::DarkGray)),
Span::styled(hosts_str, Style::default().fg(Color::White)),
]));
}
// Recipients
lines.push(Line::from(vec![
Span::styled(" Recipients: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{} age keys", secret.keys.len()),
Style::default().fg(Color::White),
),
]));
// Needed for users
if secret.needed_for_users {
lines.push(Line::from(vec![
Span::styled(" Users: ", Style::default().fg(Color::DarkGray)),
Span::styled("yes", Style::default().fg(Color::Yellow)),
]));
}
while lines.len() < 6 {
lines.push(Line::from(""));
}
// Key details section
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Keys:",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)));
for key in &secret.keys {
let truncated = if key.len() > 20 {
format!("{}...", &key[..20])
} else {
key.clone()
};
// Check if this is a master key
let label = if manifest.master_keys.contains(key) {
Span::styled(" (master)", Style::default().fg(Color::Magenta))
} else {
// Try to find which host owns this key
let host_label = manifest
.hosts
.iter()
.find(|(_, info)| info.keys.contains(key))
.map(|(name, _)| format!(" ({})", name))
.unwrap_or_default();
Span::styled(host_label, Style::default().fg(Color::Cyan))
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(truncated, Style::default().fg(Color::Green)),
label,
]));
}
let paragraph = Paragraph::new(lines).block(block).render(area, buf);
}
fn render_placeholder(area: Rect, buf: &mut Buffer, title: &str, message: &str) {
let block = Block::default()
.title(title.to_string())
.borders(Borders::ALL);
Paragraph::new(message.to_string())
.style(Style::default().fg(Color::DarkGray))
.block(block)
.render(area, buf);
}
/// Truncate a name with middle ellipsis, preserving the file extension
fn truncate_with_ellipsis(name: &str, max_width: usize) -> String {
if name.len() <= max_width {
return name.to_string();
}
if max_width < 5 {
return name[..max_width].to_string();
}
// Try to preserve the extension
if let Some(dot_pos) = name.rfind('.') {
let ext = &name[dot_pos..];
if ext.len() < max_width - 3 {
let prefix_len = max_width - 3 - ext.len();
return format!("{}...{}", &name[..prefix_len], ext);
}
}
// Fallback: just truncate with ellipsis at end
let prefix_len = max_width - 3;
format!("{}...", &name[..prefix_len])
}