shell
This commit is contained in:
@@ -111,7 +111,7 @@ impl FileExplorer {
|
||||
|
||||
let mut me = Self {
|
||||
tree_view,
|
||||
provider: Arc::new(FileDataProvider),
|
||||
provider: Arc::new(FileDataProvider::new()),
|
||||
root: root.clone(),
|
||||
expanded,
|
||||
children: HashMap::new(),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "shipote-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "CLI de administración de shipote-daemon."
|
||||
|
||||
[[bin]]
|
||||
name = "shipote"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
shipote-card = { path = "../../modules/shipote/shipote-card" }
|
||||
shipote-protocol = { path = "../../modules/shipote/shipote-protocol" }
|
||||
brahman-card = { path = "../../core/brahman-card" }
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
@@ -0,0 +1,391 @@
|
||||
//! `shipote` — CLI de administración del daemon.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use shipote_card::{load_pipeline_spec, load_workspace_spec, WorkspaceId};
|
||||
use shipote_protocol::{default_socket_path, read_frame, write_frame, Request, Response};
|
||||
use std::path::PathBuf;
|
||||
use tokio::net::UnixStream;
|
||||
use ulid::Ulid;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "shipote", version, about = "Administración de shipote-daemon")]
|
||||
struct Cli {
|
||||
/// Path al socket del daemon. Default: $XDG_RUNTIME_DIR/shipote.sock.
|
||||
#[arg(long, global = true)]
|
||||
socket: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Cmd {
|
||||
/// Health-check del daemon.
|
||||
Ping,
|
||||
|
||||
/// Capacidades runtime detectadas por el daemon.
|
||||
Caps,
|
||||
|
||||
/// Operaciones sobre Workspaces.
|
||||
#[command(subcommand)]
|
||||
Workspace(WsCmd),
|
||||
|
||||
/// Ejecutar un comando one-shot dentro de un workspace.
|
||||
Run {
|
||||
/// ULID del workspace destino.
|
||||
#[arg(short = 'w', long)]
|
||||
workspace: String,
|
||||
/// Path del ejecutable.
|
||||
exec: String,
|
||||
/// Argumentos del comando.
|
||||
argv: Vec<String>,
|
||||
},
|
||||
|
||||
/// Discernir el tipo de un archivo (ad-hoc, sin workspace).
|
||||
Discern {
|
||||
/// Path al archivo a discernir.
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
/// Listar comandos de un workspace.
|
||||
Commands {
|
||||
/// ULID del workspace.
|
||||
workspace: String,
|
||||
},
|
||||
|
||||
/// Mostrar tail del log capturado de un comando.
|
||||
Logs {
|
||||
/// ULID del workspace.
|
||||
workspace: String,
|
||||
/// ULID del comando.
|
||||
command: String,
|
||||
/// Bytes desde el final (0 = todo).
|
||||
#[arg(long, default_value_t = 0)]
|
||||
tail: usize,
|
||||
},
|
||||
|
||||
/// Pipeline DAG con flujo tipado.
|
||||
#[command(subcommand)]
|
||||
Pipeline(PipeCmd),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum PipeCmd {
|
||||
/// Lanzar un Pipeline desde un spec TOML/JSON.
|
||||
Run {
|
||||
/// Path al spec del pipeline.
|
||||
spec: PathBuf,
|
||||
/// Interponer un tap entre productor↔consumidor de cada edge para
|
||||
/// discernir el TypeRef del flujo.
|
||||
#[arg(long)]
|
||||
tap: bool,
|
||||
},
|
||||
/// Guardar un pipeline bajo un nombre (persiste con el snapshot).
|
||||
Save {
|
||||
/// Nombre simbólico.
|
||||
name: String,
|
||||
/// Path al spec.
|
||||
spec: PathBuf,
|
||||
},
|
||||
/// Listar nombres de pipelines guardados.
|
||||
SavedList,
|
||||
/// Eliminar un pipeline guardado.
|
||||
Drop { name: String },
|
||||
/// Ejecutar un pipeline guardado por nombre.
|
||||
RunSaved {
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
tap: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum WsCmd {
|
||||
/// Crear un workspace desde un spec TOML/JSON.
|
||||
Create {
|
||||
/// Path al spec del workspace.
|
||||
spec: PathBuf,
|
||||
},
|
||||
/// Listar workspaces vivos.
|
||||
List,
|
||||
/// Detener un workspace por ID.
|
||||
Stop {
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let socket = cli.socket.unwrap_or_else(default_socket_path);
|
||||
let mut stream = UnixStream::connect(&socket)
|
||||
.await
|
||||
.with_context(|| format!("connect {}", socket.display()))?;
|
||||
|
||||
match cli.cmd {
|
||||
Cmd::Ping => {
|
||||
let resp = round_trip(&mut stream, Request::Ping).await?;
|
||||
match resp {
|
||||
Response::Pong => println!("pong"),
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Caps => {
|
||||
let resp = round_trip(&mut stream, Request::Capabilities).await?;
|
||||
match resp {
|
||||
Response::Capabilities {
|
||||
kernel_version,
|
||||
user_ns,
|
||||
cgroup_v2,
|
||||
cgroup_delegated,
|
||||
has_cap_sys_admin,
|
||||
} => {
|
||||
println!("kernel: {}.{}.{}", kernel_version.0, kernel_version.1, kernel_version.2);
|
||||
println!("user_ns: {user_ns}");
|
||||
println!("cgroup_v2: {cgroup_v2}");
|
||||
println!("cgroup_delegated: {cgroup_delegated}");
|
||||
println!("cap_sys_admin: {has_cap_sys_admin}");
|
||||
}
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Workspace(WsCmd::Create { spec }) => {
|
||||
let ws = load_workspace_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
|
||||
let resp = round_trip(&mut stream, Request::WorkspaceCreate { spec: ws }).await?;
|
||||
match resp {
|
||||
Response::WorkspaceCreated { id, warnings } => {
|
||||
println!("{id}");
|
||||
for w in warnings {
|
||||
eprintln!("warning: {w}");
|
||||
}
|
||||
}
|
||||
Response::Error { message } => return Err(anyhow!(message)),
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Workspace(WsCmd::List) => {
|
||||
let resp = round_trip(&mut stream, Request::WorkspaceList).await?;
|
||||
match resp {
|
||||
Response::WorkspaceList { items } => {
|
||||
if items.is_empty() {
|
||||
println!("(no workspaces)");
|
||||
}
|
||||
for it in items {
|
||||
println!(
|
||||
"{} {:<20} cmds={} uptime={}ms",
|
||||
it.id, it.label, it.commands, it.uptime_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Workspace(WsCmd::Stop { id }) => {
|
||||
let id = parse_ws_id(&id)?;
|
||||
let resp = round_trip(&mut stream, Request::WorkspaceStop { id }).await?;
|
||||
match resp {
|
||||
Response::WorkspaceStopped { id, reaped } => {
|
||||
println!("stopped {id} (reaped {reaped})");
|
||||
}
|
||||
Response::Error { message } => return Err(anyhow!(message)),
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Run { workspace, exec, argv } => {
|
||||
let id = parse_ws_id(&workspace)?;
|
||||
let resp = round_trip(
|
||||
&mut stream,
|
||||
Request::Run {
|
||||
workspace: id,
|
||||
exec,
|
||||
argv,
|
||||
envp: vec![],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
Response::RunStarted { command_id, pid, .. } => {
|
||||
println!("{command_id} pid={pid}");
|
||||
}
|
||||
Response::Error { message } => return Err(anyhow!(message)),
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Pipeline(PipeCmd::Run { spec, tap }) => {
|
||||
let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
|
||||
let resp = round_trip(&mut stream, Request::PipelineRun { spec: p, tap }).await?;
|
||||
print_pipeline_started(resp)?;
|
||||
}
|
||||
|
||||
Cmd::Pipeline(PipeCmd::Save { name, spec }) => {
|
||||
let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
|
||||
let resp = round_trip(&mut stream, Request::PipelineSave { name: name.clone(), spec: p }).await?;
|
||||
match resp {
|
||||
Response::PipelineSaved { name } => println!("saved {name}"),
|
||||
Response::Error { message } => return Err(anyhow!(message)),
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Pipeline(PipeCmd::SavedList) => {
|
||||
let resp = round_trip(&mut stream, Request::PipelineSavedList).await?;
|
||||
match resp {
|
||||
Response::PipelineSavedList { names } => {
|
||||
if names.is_empty() {
|
||||
println!("(no saved pipelines)");
|
||||
}
|
||||
for n in names {
|
||||
println!("{n}");
|
||||
}
|
||||
}
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Pipeline(PipeCmd::Drop { name }) => {
|
||||
let resp = round_trip(&mut stream, Request::PipelineDrop { name }).await?;
|
||||
match resp {
|
||||
Response::PipelineDropped { name, existed } => {
|
||||
if existed {
|
||||
println!("dropped {name}");
|
||||
} else {
|
||||
eprintln!("no existía: {name}");
|
||||
}
|
||||
}
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Pipeline(PipeCmd::RunSaved { name, tap }) => {
|
||||
let resp = round_trip(&mut stream, Request::PipelineRunSaved { name, tap }).await?;
|
||||
print_pipeline_started(resp)?;
|
||||
}
|
||||
|
||||
Cmd::Commands { workspace } => {
|
||||
let ws = parse_ws_id(&workspace)?;
|
||||
let resp = round_trip(&mut stream, Request::CommandList { workspace: ws }).await?;
|
||||
match resp {
|
||||
Response::CommandList { items } => {
|
||||
if items.is_empty() {
|
||||
println!("(no commands)");
|
||||
}
|
||||
for c in items {
|
||||
let alive = if c.alive { "alive" } else { "exited" };
|
||||
let exit = c
|
||||
.exit_status
|
||||
.map(|s| format!("exit={s}"))
|
||||
.unwrap_or_default();
|
||||
println!(
|
||||
"{} {:<24} pid={:<7} {:<8} logs={} {}",
|
||||
c.id, c.label, c.pid, alive, c.log_bytes, exit
|
||||
);
|
||||
}
|
||||
}
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Logs { workspace, command, tail } => {
|
||||
let ws = parse_ws_id(&workspace)?;
|
||||
let cmd_id = Ulid::from_string(&command).map_err(|e| anyhow!("invalid command id: {e}"))?;
|
||||
let resp = round_trip(
|
||||
&mut stream,
|
||||
Request::CommandLogs {
|
||||
workspace: ws,
|
||||
command: cmd_id,
|
||||
tail_bytes: tail,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
Response::CommandLogs { bytes } => {
|
||||
// stdout raw, sin decoding — el log puede tener bytes binarios.
|
||||
use std::io::Write;
|
||||
let _ = std::io::stdout().write_all(&bytes);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
Response::Error { message } => return Err(anyhow!(message)),
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Discern { path } => {
|
||||
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
// Sample: hasta 4 KiB.
|
||||
let sample = bytes.into_iter().take(4096).collect();
|
||||
let resp = round_trip(
|
||||
&mut stream,
|
||||
Request::Discern {
|
||||
sample,
|
||||
hint_path: Some(path),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
Response::Discernment { ty, confidence, mime, lens } => {
|
||||
println!("type: {ty}");
|
||||
println!("confidence: {confidence:.2}");
|
||||
if let Some(m) = mime {
|
||||
println!("mime: {m}");
|
||||
}
|
||||
if let Some(l) = lens {
|
||||
println!("lens: {l}");
|
||||
}
|
||||
}
|
||||
Response::Error { message } => return Err(anyhow!(message)),
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn round_trip(stream: &mut UnixStream, req: Request) -> Result<Response> {
|
||||
write_frame(stream, &req).await?;
|
||||
let resp: Response = read_frame(stream).await?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
fn parse_ws_id(s: &str) -> Result<WorkspaceId> {
|
||||
let u = Ulid::from_string(s).map_err(|e| anyhow!("invalid workspace id: {e}"))?;
|
||||
Ok(WorkspaceId(u))
|
||||
}
|
||||
|
||||
fn print_unexpected(r: &Response) {
|
||||
eprintln!("unexpected response: {r:?}");
|
||||
}
|
||||
|
||||
fn print_pipeline_started(resp: Response) -> Result<()> {
|
||||
match resp {
|
||||
Response::PipelineStarted { pipeline, command_pids, edges } => {
|
||||
println!("pipeline {pipeline}");
|
||||
for (label, pid) in command_pids {
|
||||
println!(" {:<20} pid={pid}", label);
|
||||
}
|
||||
if !edges.is_empty() {
|
||||
println!("edges:");
|
||||
for e in edges {
|
||||
println!(
|
||||
" {}.{} → {}.{} ty={:?} mime={:?} conf={:.2}",
|
||||
e.from_label, e.from_output, e.to_label, e.to_input,
|
||||
e.ty, e.mime, e.confidence,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Response::Error { message } => Err(anyhow!(message)),
|
||||
other => {
|
||||
print_unexpected(&other);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "shipote-daemon"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Daemon de shipote: dueño de los Workspaces, expone admin socket para shipote-cli."
|
||||
|
||||
[[bin]]
|
||||
name = "shipote-daemon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
shipote-card = { path = "../../modules/shipote/shipote-card" }
|
||||
shipote-protocol = { path = "../../modules/shipote/shipote-protocol" }
|
||||
shipote-discern = { path = "../../modules/shipote/shipote-discern" }
|
||||
shipote-core = { path = "../../modules/shipote/shipote-core" }
|
||||
ente-incarnate = { path = "../../shared/ente-incarnate" }
|
||||
brahman-card = { path = "../../core/brahman-card" }
|
||||
brahman-sidecar = { path = "../../shared/brahman-sidecar" }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
nix = { workspace = true }
|
||||
@@ -0,0 +1,361 @@
|
||||
//! `shipote-daemon` — punto de entrada del runtime de shipote.
|
||||
//!
|
||||
//! Responsabilidades:
|
||||
//! - Escuchar el Unix socket admin (default: `$XDG_RUNTIME_DIR/shipote.sock`).
|
||||
//! - Despachar mensajes del [`shipote_protocol`] al [`WorkspaceManager`].
|
||||
//! - Reapear hijos periódicamente.
|
||||
//!
|
||||
//! Lo que NO hace en v1:
|
||||
//! - Sidecar al broker / handshake con Init (futuro: cuando un workspace
|
||||
//! exponga `service_socket`, anunciar al broker).
|
||||
//! - GUI (futuro `shipote-shell` con yahweh_launcher).
|
||||
|
||||
use anyhow::Context;
|
||||
use brahman_card::{Card, CardKind, Flow, Flows, Lifecycle, Payload, Supervision, TypeRef};
|
||||
use ente_incarnate::IncarnatorConfig;
|
||||
use shipote_core::WorkspaceManager;
|
||||
use shipote_discern::{DiscernPipeline, Hint};
|
||||
use shipote_protocol::{
|
||||
default_socket_path, read_frame, write_frame, CommandInfo as ProtoCommandInfo,
|
||||
EdgeDiscernmentInfo, Request, Response, WorkspaceSummary,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
init_tracing();
|
||||
let sock = default_socket_path();
|
||||
if sock.exists() {
|
||||
// Si ya existe, asumimos restart limpio. Si hubiera otro daemon vivo,
|
||||
// bind fallaría con EADDRINUSE — más adelante: lockfile + check de PID.
|
||||
let _ = std::fs::remove_file(&sock);
|
||||
}
|
||||
if let Some(parent) = sock.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
|
||||
info!(socket = %sock.display(), "shipote-daemon listening");
|
||||
|
||||
// Sidecar al broker: shipote se anuncia como sesión. Si el Init no
|
||||
// está corriendo, el sidecar loguea y termina; el daemon sigue
|
||||
// standalone (UX de v1: ningún feature requiere broker).
|
||||
brahman_sidecar::spawn(build_daemon_card(&sock));
|
||||
|
||||
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig {
|
||||
// El daemon aún no se conecta al broker; cuando lo haga, este path
|
||||
// se llenará desde el handshake.
|
||||
bus_sock: None,
|
||||
notify_socket: None,
|
||||
extra_env: vec![("SHIPOTE_DAEMON".into(), "1".into())],
|
||||
// strict_caps=false en v1: queremos UX permisiva (correr en non-root
|
||||
// sin user_ns y avisar via warnings, no abortar).
|
||||
strict_caps: false,
|
||||
}));
|
||||
|
||||
// Restaurar snapshot previo si existe. Workspaces se recrean; los
|
||||
// pids de comandos viejos NO se recuperan (kernel los mató).
|
||||
let snapshot_path = shipote_core::persist::default_snapshot_path();
|
||||
if let Err(e) = mgr.restore_snapshot(&snapshot_path).await {
|
||||
warn!(?e, "restore_snapshot falló — start fresh");
|
||||
}
|
||||
|
||||
// Save-on-shutdown via SIGTERM/SIGINT handler. tokio::signal soporta
|
||||
// ambos en Linux.
|
||||
{
|
||||
let mgr = mgr.clone();
|
||||
let path = snapshot_path.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("SIGTERM handler");
|
||||
let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
|
||||
.expect("SIGINT handler");
|
||||
tokio::select! {
|
||||
_ = term.recv() => info!("SIGTERM — saving snapshot"),
|
||||
_ = int.recv() => info!("SIGINT — saving snapshot"),
|
||||
}
|
||||
if let Err(e) = mgr.save_snapshot(&path).await {
|
||||
warn!(?e, "save_snapshot falló");
|
||||
}
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
let discerner = Arc::new(DiscernPipeline::default_pipeline());
|
||||
|
||||
// Reaper periódico cada 500 ms.
|
||||
{
|
||||
let mgr = mgr.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_millis(500));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
mgr.reap_dead().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
let mgr = mgr.clone();
|
||||
let disc = discerner.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_client(stream, mgr, disc).await {
|
||||
warn!(?e, "client handler error");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!(?e, "accept failed");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client(
|
||||
mut stream: UnixStream,
|
||||
mgr: Arc<WorkspaceManager>,
|
||||
disc: Arc<DiscernPipeline>,
|
||||
) -> anyhow::Result<()> {
|
||||
loop {
|
||||
let req: Request = match read_frame(&mut stream).await {
|
||||
Ok(r) => r,
|
||||
Err(shipote_protocol::ProtocolError::Closed) => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let resp = dispatch(&mgr, &disc, req).await;
|
||||
write_frame(&mut stream, &resp).await?;
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Request) -> Response {
|
||||
match req {
|
||||
Request::Ping => Response::Pong,
|
||||
|
||||
Request::WorkspaceCreate { spec } => match mgr.create(spec).await {
|
||||
Ok((id, warnings)) => Response::WorkspaceCreated { id, warnings },
|
||||
Err(e) => Response::Error { message: format!("{e}") },
|
||||
},
|
||||
|
||||
Request::WorkspaceList => {
|
||||
let items = mgr
|
||||
.list()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|s| WorkspaceSummary {
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
commands: s.commands,
|
||||
uptime_ms: s.uptime_ms,
|
||||
})
|
||||
.collect();
|
||||
Response::WorkspaceList { items }
|
||||
}
|
||||
|
||||
Request::WorkspaceStop { id } => match mgr.stop(id).await {
|
||||
Ok(reaped) => Response::WorkspaceStopped { id, reaped },
|
||||
Err(e) => Response::Error { message: format!("{e}") },
|
||||
},
|
||||
|
||||
Request::Run { workspace, exec, argv, envp } => {
|
||||
match mgr.run(workspace, exec, argv, envp).await {
|
||||
Ok(s) => Response::RunStarted {
|
||||
workspace,
|
||||
command_id: s.id,
|
||||
pid: s.pid,
|
||||
},
|
||||
Err(e) => Response::Error { message: format!("{e}") },
|
||||
}
|
||||
}
|
||||
|
||||
Request::PipelineRun { spec, tap } => {
|
||||
let disc = DiscernPipeline::default_pipeline();
|
||||
let inc = mgr.incarnator_handle();
|
||||
let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default();
|
||||
match shipote_core::pipeline::run_pipeline(
|
||||
&spec,
|
||||
&ws_label,
|
||||
tap,
|
||||
std::sync::Arc::new(disc),
|
||||
inc,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(launch) => Response::PipelineStarted {
|
||||
pipeline: launch.pipeline,
|
||||
command_pids: launch.command_pids,
|
||||
edges: launch
|
||||
.edge_discernments
|
||||
.into_iter()
|
||||
.map(|e| EdgeDiscernmentInfo {
|
||||
from_label: e.from_label,
|
||||
from_output: e.from_output,
|
||||
to_label: e.to_label,
|
||||
to_input: e.to_input,
|
||||
ty: e.discernment.as_ref().map(|d| format!("{:?}", d.ty)),
|
||||
mime: e.discernment.as_ref().and_then(|d| d.mime.clone()),
|
||||
lens: e.discernment.as_ref().and_then(|d| d.lens.clone()),
|
||||
confidence: e.discernment.as_ref().map(|d| d.confidence).unwrap_or(0.0),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
Err(e) => Response::Error { message: format!("{e}") },
|
||||
}
|
||||
}
|
||||
|
||||
Request::Discern { sample, hint_path } => {
|
||||
let path_str = hint_path.as_ref().and_then(|p| p.to_str());
|
||||
let hint = Hint {
|
||||
path: path_str,
|
||||
size_total: None,
|
||||
};
|
||||
match disc.discern(&sample, &hint) {
|
||||
Some(d) => Response::Discernment {
|
||||
ty: format!("{:?}", d.ty),
|
||||
confidence: d.confidence,
|
||||
mime: d.mime,
|
||||
lens: d.lens,
|
||||
},
|
||||
None => Response::Error { message: "no discernment".into() },
|
||||
}
|
||||
}
|
||||
|
||||
Request::CommandList { workspace } => {
|
||||
let items: Vec<ProtoCommandInfo> = mgr
|
||||
.list_commands(workspace)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|c| ProtoCommandInfo {
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
pid: c.pid,
|
||||
alive: c.alive,
|
||||
exit_status: c.exit_status,
|
||||
log_bytes: c.log_bytes,
|
||||
})
|
||||
.collect();
|
||||
Response::CommandList { items }
|
||||
}
|
||||
|
||||
Request::CommandLogs { workspace, command, tail_bytes } => {
|
||||
match mgr.get_command_logs(workspace, command, tail_bytes).await {
|
||||
Some(bytes) => Response::CommandLogs { bytes },
|
||||
None => Response::Error {
|
||||
message: format!("no logs for command {command} in workspace {workspace}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Request::PipelineSave { name, spec } => {
|
||||
mgr.save_pipeline(name.clone(), spec).await;
|
||||
Response::PipelineSaved { name }
|
||||
}
|
||||
|
||||
Request::PipelineSavedList => {
|
||||
let names = mgr.list_saved_pipelines().await;
|
||||
Response::PipelineSavedList { names }
|
||||
}
|
||||
|
||||
Request::PipelineDrop { name } => {
|
||||
let existed = mgr.drop_saved_pipeline(&name).await;
|
||||
Response::PipelineDropped { name, existed }
|
||||
}
|
||||
|
||||
Request::PipelineRunSaved { name, tap } => match mgr.get_saved_pipeline(&name).await {
|
||||
Some(spec) => {
|
||||
let disc = DiscernPipeline::default_pipeline();
|
||||
let inc = mgr.incarnator_handle();
|
||||
let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default();
|
||||
match shipote_core::pipeline::run_pipeline(
|
||||
&spec,
|
||||
&ws_label,
|
||||
tap,
|
||||
std::sync::Arc::new(disc),
|
||||
inc,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(launch) => Response::PipelineStarted {
|
||||
pipeline: launch.pipeline,
|
||||
command_pids: launch.command_pids,
|
||||
edges: launch
|
||||
.edge_discernments
|
||||
.into_iter()
|
||||
.map(|e| EdgeDiscernmentInfo {
|
||||
from_label: e.from_label,
|
||||
from_output: e.from_output,
|
||||
to_label: e.to_label,
|
||||
to_input: e.to_input,
|
||||
ty: e.discernment.as_ref().map(|d| format!("{:?}", d.ty)),
|
||||
mime: e.discernment.as_ref().and_then(|d| d.mime.clone()),
|
||||
lens: e.discernment.as_ref().and_then(|d| d.lens.clone()),
|
||||
confidence: e.discernment.as_ref().map(|d| d.confidence).unwrap_or(0.0),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
Err(e) => Response::Error { message: format!("{e}") },
|
||||
}
|
||||
}
|
||||
None => Response::Error {
|
||||
message: format!("pipeline `{name}` no encontrado"),
|
||||
},
|
||||
},
|
||||
|
||||
Request::Capabilities => {
|
||||
let c = mgr.incarnator().capabilities();
|
||||
Response::Capabilities {
|
||||
kernel_version: c.kernel_version,
|
||||
user_ns: format!("{:?}", c.user_ns),
|
||||
cgroup_v2: format!("{:?}", c.cgroup_v2),
|
||||
cgroup_delegated: c.cgroup_delegated,
|
||||
has_cap_sys_admin: c.has_cap_sys_admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Card del daemon. La presentamos al broker así otras sesiones pueden
|
||||
/// descubrir que shipote está corriendo y, eventualmente, conectarse
|
||||
/// como consumidoras del flow `workspaces` (futuro: que la GUI o el
|
||||
/// broker-explorer los listen vía broker en lugar de socket directo).
|
||||
fn build_daemon_card(service_socket: &std::path::Path) -> Card {
|
||||
let mut card = Card::new("shipote.daemon");
|
||||
card.kind = CardKind::Ente;
|
||||
card.lifecycle = Lifecycle::Daemon;
|
||||
card.payload = Payload::Virtual; // el daemon ya está corriendo (no es PID 1 quien lo encarna)
|
||||
card.supervision = Supervision::Delegate;
|
||||
card.service_socket = Some(service_socket.to_path_buf());
|
||||
card.flow = Flows {
|
||||
input: Vec::new(),
|
||||
output: vec![
|
||||
Flow {
|
||||
name: "workspaces".into(),
|
||||
ty: TypeRef::Wit {
|
||||
package: "shipote:admin".into(),
|
||||
interface: None,
|
||||
name: "workspace-list".into(),
|
||||
},
|
||||
pin_to: None,
|
||||
},
|
||||
Flow {
|
||||
name: "discern".into(),
|
||||
ty: TypeRef::Wit {
|
||||
package: "shipote:admin".into(),
|
||||
interface: None,
|
||||
name: "discernment".into(),
|
||||
},
|
||||
pin_to: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
card
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
let filter = EnvFilter::try_from_env("SHIPOTE_LOG").unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
fmt().with_env_filter(filter).init();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "shipote-shell"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "GUI de shipote: vista de Workspaces+comandos+capabilities. Conecta al daemon vía shipote-protocol."
|
||||
|
||||
[[bin]]
|
||||
name = "shipote-shell"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
shipote-card = { path = "../../modules/shipote/shipote-card" }
|
||||
shipote-protocol = { path = "../../modules/shipote/shipote-protocol" }
|
||||
yahweh-theme = { path = "../../modules/ui_engine/libs/theme" }
|
||||
yahweh-launcher = { path = "../../modules/ui_engine/libs/launcher" }
|
||||
yahweh-widget-banner = { path = "../../modules/ui_engine/widgets/banner" }
|
||||
yahweh-widget-stat-card = { path = "../../modules/ui_engine/widgets/stat-card" }
|
||||
yahweh-widget-app-header = { path = "../../modules/ui_engine/widgets/app-header" }
|
||||
tokio = { workspace = true }
|
||||
gpui = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
@@ -0,0 +1,363 @@
|
||||
//! `shipote-shell` — GUI dashboard del daemon shipote.
|
||||
//!
|
||||
//! Probe-style: conecta al socket del daemon cada 2s, pide
|
||||
//! capabilities + workspace-list y los muestra en cards.
|
||||
//! Si el daemon no está corriendo, marca DOWN.
|
||||
|
||||
use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window};
|
||||
use shipote_protocol::{
|
||||
default_socket_path, read_frame, write_frame, CommandInfo, Request, Response, WorkspaceSummary,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::net::UnixStream;
|
||||
use yahweh_launcher::launch_app;
|
||||
use yahweh_theme::Theme;
|
||||
use yahweh_widget_app_header::app_header;
|
||||
use yahweh_widget_banner::{banner_themed, Banner};
|
||||
use yahweh_widget_stat_card::stat_card;
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum DaemonState {
|
||||
Pending,
|
||||
Down { reason: String },
|
||||
Up,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct CapsSummary {
|
||||
kernel_version: (u32, u32, u32),
|
||||
user_ns: String,
|
||||
cgroup_v2: String,
|
||||
cgroup_delegated: bool,
|
||||
has_cap_sys_admin: bool,
|
||||
}
|
||||
|
||||
struct Shell {
|
||||
socket_path: PathBuf,
|
||||
state: DaemonState,
|
||||
workspaces: Vec<WorkspaceSummary>,
|
||||
/// Comandos por workspace, indexados por workspace id.toString().
|
||||
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
|
||||
saved_pipelines: Vec<String>,
|
||||
caps: Option<CapsSummary>,
|
||||
last_probe_ms: u64,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
launch_app("Shipote — Shell", (820., 560.), Shell::new);
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let socket_path = default_socket_path();
|
||||
let socket_for_loop = socket_path.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let timer = cx.background_executor().clone();
|
||||
let bg = cx.background_executor().clone();
|
||||
loop {
|
||||
let path = socket_for_loop.clone();
|
||||
let started = std::time::Instant::now();
|
||||
let result = bg
|
||||
.spawn(async move { probe_blocking(&path) })
|
||||
.await;
|
||||
let elapsed = started.elapsed().as_millis() as u64;
|
||||
let _ = this.update(cx, |me, cx| {
|
||||
match result {
|
||||
Ok(snap) => {
|
||||
me.state = DaemonState::Up;
|
||||
me.workspaces = snap.workspaces;
|
||||
me.commands = snap.commands;
|
||||
me.saved_pipelines = snap.saved_pipelines;
|
||||
me.caps = Some(snap.caps);
|
||||
}
|
||||
Err(reason) => {
|
||||
me.state = DaemonState::Down { reason };
|
||||
me.workspaces.clear();
|
||||
me.commands.clear();
|
||||
me.saved_pipelines.clear();
|
||||
me.caps = None;
|
||||
}
|
||||
}
|
||||
me.last_probe_ms = elapsed;
|
||||
cx.notify();
|
||||
});
|
||||
timer.timer(POLL_INTERVAL).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
socket_path,
|
||||
state: DaemonState::Pending,
|
||||
workspaces: Vec::new(),
|
||||
commands: std::collections::BTreeMap::new(),
|
||||
saved_pipelines: Vec::new(),
|
||||
caps: None,
|
||||
last_probe_ms: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Snapshot {
|
||||
workspaces: Vec<WorkspaceSummary>,
|
||||
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
|
||||
saved_pipelines: Vec<String>,
|
||||
caps: CapsSummary,
|
||||
}
|
||||
|
||||
fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
||||
// Mini tokio runtime efímero por probe — no compartimos runtime con
|
||||
// GPUI. Costo aceptable cada 2s: setup ≈ <1 ms.
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|e| format!("rt: {e}"))?;
|
||||
rt.block_on(async {
|
||||
let mut stream = UnixStream::connect(path)
|
||||
.await
|
||||
.map_err(|e| format!("connect: {e}"))?;
|
||||
write_frame(&mut stream, &Request::WorkspaceList)
|
||||
.await
|
||||
.map_err(|e| format!("write list: {e}"))?;
|
||||
let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read list: {e}"))?;
|
||||
let workspaces = match resp {
|
||||
Response::WorkspaceList { items } => items,
|
||||
other => return Err(format!("unexpected list resp: {other:?}")),
|
||||
};
|
||||
|
||||
// Commands por workspace.
|
||||
let mut commands_map = std::collections::BTreeMap::new();
|
||||
for w in &workspaces {
|
||||
write_frame(&mut stream, &Request::CommandList { workspace: w.id })
|
||||
.await
|
||||
.map_err(|e| format!("write commands: {e}"))?;
|
||||
let resp: Response = read_frame(&mut stream)
|
||||
.await
|
||||
.map_err(|e| format!("read commands: {e}"))?;
|
||||
if let Response::CommandList { items } = resp {
|
||||
if !items.is_empty() {
|
||||
commands_map.insert(w.id.to_string(), items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Saved pipelines.
|
||||
write_frame(&mut stream, &Request::PipelineSavedList)
|
||||
.await
|
||||
.map_err(|e| format!("write saved: {e}"))?;
|
||||
let resp: Response = read_frame(&mut stream)
|
||||
.await
|
||||
.map_err(|e| format!("read saved: {e}"))?;
|
||||
let saved_pipelines = match resp {
|
||||
Response::PipelineSavedList { names } => names,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
write_frame(&mut stream, &Request::Capabilities)
|
||||
.await
|
||||
.map_err(|e| format!("write caps: {e}"))?;
|
||||
let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read caps: {e}"))?;
|
||||
let caps = match resp {
|
||||
Response::Capabilities {
|
||||
kernel_version,
|
||||
user_ns,
|
||||
cgroup_v2,
|
||||
cgroup_delegated,
|
||||
has_cap_sys_admin,
|
||||
} => CapsSummary {
|
||||
kernel_version,
|
||||
user_ns,
|
||||
cgroup_v2,
|
||||
cgroup_delegated,
|
||||
has_cap_sys_admin,
|
||||
},
|
||||
other => return Err(format!("unexpected caps resp: {other:?}")),
|
||||
};
|
||||
Ok(Snapshot {
|
||||
workspaces,
|
||||
commands: commands_map,
|
||||
saved_pipelines,
|
||||
caps,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
impl Render for Shell {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
let bg = theme.bg_app.clone();
|
||||
let text = theme.fg_text;
|
||||
let text_dim = theme.fg_muted;
|
||||
|
||||
let accent_up = gpui::rgb(0xa3be8c);
|
||||
let accent_down = gpui::rgb(0xbf616a);
|
||||
let accent_pending = gpui::rgb(0x6a7280);
|
||||
let accent_info = gpui::rgb(0x88c0d0);
|
||||
|
||||
let header_text = format!(
|
||||
"Daemon: {} · reload {} ms",
|
||||
self.socket_path.display(),
|
||||
self.last_probe_ms
|
||||
);
|
||||
let header = app_header(cx, header_text);
|
||||
|
||||
let status_banner = match &self.state {
|
||||
DaemonState::Pending => None,
|
||||
DaemonState::Down { reason } => Some(banner_themed(
|
||||
cx,
|
||||
Banner::Error,
|
||||
SharedString::from(format!("Daemon DOWN — {reason}")),
|
||||
)),
|
||||
DaemonState::Up => Some(banner_themed(
|
||||
cx,
|
||||
Banner::Success,
|
||||
SharedString::from("Daemon UP"),
|
||||
)),
|
||||
};
|
||||
|
||||
let (status_value, status_descr, status_accent) = match &self.state {
|
||||
DaemonState::Pending => ("PENDING".to_string(), "primer probe…".to_string(), accent_pending),
|
||||
DaemonState::Down { reason } => ("DOWN".to_string(), reason.clone(), accent_down),
|
||||
DaemonState::Up => ("UP".to_string(), "shipote-daemon respondiendo".to_string(), accent_up),
|
||||
};
|
||||
|
||||
let caps_items: Vec<String> = self
|
||||
.caps
|
||||
.as_ref()
|
||||
.map(|c| {
|
||||
vec![
|
||||
format!(
|
||||
"kernel: {}.{}.{}",
|
||||
c.kernel_version.0, c.kernel_version.1, c.kernel_version.2
|
||||
),
|
||||
format!("user_ns: {}", c.user_ns),
|
||||
format!("cgroup_v2: {}", c.cgroup_v2),
|
||||
format!("cgroup_delegated: {}", c.cgroup_delegated),
|
||||
format!("cap_sys_admin: {}", c.has_cap_sys_admin),
|
||||
]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let caps_value = if self.caps.is_some() { "OK".to_string() } else { "—".to_string() };
|
||||
|
||||
let ws_items: Vec<String> = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|w| format!("{} {:<20} cmds={} uptime={}ms", w.id, w.label, w.commands, w.uptime_ms))
|
||||
.collect();
|
||||
let ws_count = self.workspaces.len().to_string();
|
||||
let ws_descr = if self.workspaces.is_empty() {
|
||||
"no hay workspaces vivos".to_string()
|
||||
} else {
|
||||
"id · label · cmds · uptime".to_string()
|
||||
};
|
||||
|
||||
// Comandos: aplanamos por workspace, tomamos los más recientes (orden ULID ya temporal).
|
||||
let mut cmd_items: Vec<String> = Vec::new();
|
||||
let mut cmd_total = 0usize;
|
||||
for (ws_id, cmds) in &self.commands {
|
||||
cmd_total += cmds.len();
|
||||
for c in cmds.iter().rev().take(8) {
|
||||
let alive = if c.alive { "▶" } else { "✓" };
|
||||
let exit = c
|
||||
.exit_status
|
||||
.map(|s| format!(" exit={s}"))
|
||||
.unwrap_or_default();
|
||||
cmd_items.push(format!(
|
||||
"{} {} {:<20} pid={} logs={}B{}",
|
||||
alive,
|
||||
&ws_id[..6.min(ws_id.len())],
|
||||
c.label,
|
||||
c.pid,
|
||||
c.log_bytes,
|
||||
exit
|
||||
));
|
||||
}
|
||||
}
|
||||
let cmd_count = cmd_total.to_string();
|
||||
let cmd_descr = if cmd_total == 0 {
|
||||
"no hay comandos lanzados".to_string()
|
||||
} else {
|
||||
"▶=alive ✓=exited · ws_prefix · label · pid · logs".to_string()
|
||||
};
|
||||
|
||||
// Saved pipelines.
|
||||
let saved_count = self.saved_pipelines.len().to_string();
|
||||
let saved_items: Vec<String> = self.saved_pipelines.clone();
|
||||
let saved_descr = if saved_items.is_empty() {
|
||||
"shipote pipeline save <name> <file> para persistir".to_string()
|
||||
} else {
|
||||
"definiciones reusables vía run-saved".to_string()
|
||||
};
|
||||
|
||||
let body = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(8.))
|
||||
.px(px(16.))
|
||||
.py(px(16.))
|
||||
.child(stat_card(
|
||||
cx,
|
||||
"Estado",
|
||||
status_value,
|
||||
&status_descr,
|
||||
status_accent,
|
||||
text,
|
||||
text_dim,
|
||||
&[],
|
||||
))
|
||||
.child(stat_card(
|
||||
cx,
|
||||
"Capabilities",
|
||||
caps_value,
|
||||
"kernel + namespaces + cgroup delegation",
|
||||
accent_info,
|
||||
text,
|
||||
text_dim,
|
||||
&caps_items,
|
||||
))
|
||||
.child(stat_card(
|
||||
cx,
|
||||
"Workspaces",
|
||||
ws_count,
|
||||
&ws_descr,
|
||||
accent_info,
|
||||
text,
|
||||
text_dim,
|
||||
&ws_items,
|
||||
))
|
||||
.child(stat_card(
|
||||
cx,
|
||||
"Comandos",
|
||||
cmd_count,
|
||||
&cmd_descr,
|
||||
accent_info,
|
||||
text,
|
||||
text_dim,
|
||||
&cmd_items,
|
||||
))
|
||||
.child(stat_card(
|
||||
cx,
|
||||
"Saved pipelines",
|
||||
saved_count,
|
||||
&saved_descr,
|
||||
accent_info,
|
||||
text,
|
||||
text_dim,
|
||||
&saved_items,
|
||||
));
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
.bg(bg)
|
||||
.child(header)
|
||||
.when_some(status_banner, |d, b| d.child(b))
|
||||
.child(body)
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ impl TextViewer {
|
||||
}
|
||||
|
||||
fn spawn_load_fs(&self, path: String, gen: u64, cx: &mut Context<Self>) {
|
||||
let provider = Arc::new(FileDataProvider);
|
||||
let provider = Arc::new(FileDataProvider::new());
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = provider.get_data(&path).await;
|
||||
let _ = this.update(cx, |this, cx| this.on_loaded(gen, result, cx));
|
||||
|
||||
Reference in New Issue
Block a user