Implement the terminal UI
This commit is contained in:
1094
Cargo.lock
generated
1094
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,9 @@ fastembed = { version = "5.17.3", default-features = false, features = ["hf-hub-
|
||||
usearch = "2.26"
|
||||
ort = { version = "=2.0.0-rc.12", default-features = false, features = ["std", "api-24", "coreml", "directml"] }
|
||||
russh = "0.62.2"
|
||||
ratatui = "0.30.2"
|
||||
ratatui-image = { version = "11.0.6", default-features = false, features = ["crossterm", "image-defaults"] }
|
||||
tui-markdown = "0.3.8"
|
||||
|
||||
# UI framework
|
||||
iced = { version = "0.13", features = ["wgpu", "advanced", "canvas", "image", "svg", "tokio", "markdown"] }
|
||||
|
||||
@@ -18,6 +18,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
||||
- Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
|
||||
- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`.
|
||||
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
|
||||
- A full Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client updates, locale changes, and airplane-mode AI gating.
|
||||
- Headless `bds-server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
|
||||
- Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups.
|
||||
- Local preview in the app or system browser.
|
||||
@@ -53,4 +54,4 @@ Contributor workflow and project invariants are documented in [AGENTS.md](AGENTS
|
||||
|
||||
## Headless server
|
||||
|
||||
Run `bds-server` for a dedicated headless process, or set `BDS_MODE=server` when launching `bds-ui`. The SSH listener defaults to `127.0.0.1:2222`; use `--bind`/`BDS_SSH_BIND` and `--port`/`BDS_SSH_PORT` to opt into another address. Startup prints the private `authorized_keys` path. The desktop creates its own private `id_ed25519.pub`; add that public key to the server file, then use **File → Connect to Server…** and select a remote project. Host keys are recorded on first connection and verified thereafter. `BDS_MODE=tui` starts the same headless host with a terminal session attached to the launching shell.
|
||||
Run `bds-server` for a dedicated headless process, or set `BDS_MODE=server` when launching `bds-ui`. The SSH listener defaults to `127.0.0.1:2222`; use `--bind`/`BDS_SSH_BIND` and `--port`/`BDS_SSH_PORT` to opt into another address. Startup prints the private `authorized_keys` path. The desktop creates its own private `id_ed25519.pub`; add that public key to the server file, then use **File → Connect to Server…** and select a remote project. Host keys are recorded on first connection and verified thereafter. Run `bds-cli tui` or set `BDS_MODE=tui` for the local terminal workspace; an authenticated SSH shell opens the same workspace against server-side data and locale. Press `:` for its command list and `:?` for help.
|
||||
|
||||
@@ -99,13 +99,14 @@ Done:
|
||||
- Versioned native remote-project sessions provide protocol negotiation, server locale, project selection, shared engine calls, replay-safe request IDs, ordered domain events, task snapshots, reconnect, concurrent clients, and graceful shutdown.
|
||||
- The desktop uses a restrictive generated Ed25519 identity and TOFU `known_hosts`, with localized File-menu connect/project selection/open/failure/disconnect states. SSH shell channels host server-side terminal sessions and direct forwarding is restricted to the server-owned loopback endpoint.
|
||||
|
||||
### Terminal UI — Open
|
||||
### Terminal UI — Complete
|
||||
|
||||
Open:
|
||||
Done:
|
||||
|
||||
- Terminal renderer over shared application workflows.
|
||||
- Sidebar/editor navigation, editing, publishing, and live domain-event updates.
|
||||
- Remote operation through the headless server.
|
||||
- One Ratatui state/renderer runs locally through the CLI and in authenticated SSH PTYs; resize, disconnect, reconnect, clean exit, and terminal restoration are covered by a real loopback PTY test.
|
||||
- Numbered sidebar views, per-view filtering, project switching/path completion, command overlays, Markdown preview, syntax-highlighted soft-wrapped Markdown/Liquid/Lua editing, validation, save/publish/unpublish, confirmations, and live domain-event synchronization use the shared engines.
|
||||
- Typed settings, complete tag workflows, Git status/diff/history/commit/pull/push, metadata/site reports with apply/cancel, managed background progress, locale changes, media information, and airplane-gated AI actions are terminal-accessible.
|
||||
- State, renderer-buffer, input-decoder, shared local/remote persistence, task/report, event, locale, and real SSH PTY behavior have isolated tests.
|
||||
|
||||
### A2UI surfaces — Complete
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bds-core = { workspace = true }
|
||||
bds-server = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
|
||||
@@ -20,6 +20,27 @@ fn main() -> ExitCode {
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(cli.command, bds_cli::Command::Tui) {
|
||||
let data_root = bds_core::util::application_data_dir();
|
||||
let config = match bds_server::ServerConfig::from_environment(
|
||||
bds_core::util::application_database_path(),
|
||||
data_root,
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error:#}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
return match bds_server::run_headless(bds_server::boot::BootMode::Tui, config) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error:#}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let json = cli.json;
|
||||
let needs_stdin = match &cli.command {
|
||||
bds_cli::Command::Post(args) => args.stdin,
|
||||
|
||||
@@ -6,13 +6,19 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bds-core = { workspace = true }
|
||||
bds-editor = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
image = { workspace = true }
|
||||
russh = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
ratatui-image = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tui-markdown = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -15,44 +15,7 @@ use serde_json::{Value, json};
|
||||
use crate::protocol::{Command, PROTOCOL_VERSION, RemoteTask, Request, ServerMessage};
|
||||
|
||||
pub fn run_local_terminal(host: ApplicationHost) -> Result<()> {
|
||||
use std::io::{BufRead as _, Write as _};
|
||||
|
||||
let mut session = host.session()?;
|
||||
let _ = session.handle(Request {
|
||||
id: "local-terminal-hello".into(),
|
||||
command: Command::Hello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
},
|
||||
});
|
||||
let projects = session.handle(Request {
|
||||
id: "local-terminal-projects".into(),
|
||||
command: Command::ListProjects,
|
||||
});
|
||||
let names = match projects {
|
||||
ServerMessage::Response { result, .. } => result
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|project| project.get("name").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n • "),
|
||||
_ => String::new(),
|
||||
};
|
||||
let locale = bds_core::i18n::normalize_language(session.locale());
|
||||
let title = bds_core::i18n::translate(locale, "remoteTerminal.localTitle");
|
||||
let available = bds_core::i18n::translate(locale, "remoteTerminal.availableProjects");
|
||||
let quit = bds_core::i18n::translate(locale, "remoteTerminal.quit");
|
||||
println!(
|
||||
"\x1b[2J\x1b[H{title}\n\n{available}:\n • {}\n\n{quit}",
|
||||
if names.is_empty() { "—" } else { &names }
|
||||
);
|
||||
std::io::stdout().flush()?;
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
if line?.trim().eq_ignore_ascii_case("q") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
crate::tui::run_local(host)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -206,10 +169,22 @@ impl ApplicationHost {
|
||||
})
|
||||
}
|
||||
|
||||
fn database(&self) -> Result<Database> {
|
||||
pub(crate) fn database(&self) -> Result<Database> {
|
||||
Database::open(&self.inner.database_path).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) fn database_path(&self) -> &std::path::Path {
|
||||
&self.inner.database_path
|
||||
}
|
||||
|
||||
pub(crate) fn project_data_dir(&self, project: &bds_core::model::Project) -> PathBuf {
|
||||
project
|
||||
.data_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| self.inner.data_root.join("projects").join(&project.id))
|
||||
}
|
||||
|
||||
fn cached(&self, id: &str) -> Option<ServerMessage> {
|
||||
lock(&self.inner.completed_requests).get(id).cloned()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod client;
|
||||
pub mod host;
|
||||
pub mod protocol;
|
||||
pub mod transport;
|
||||
pub mod tui;
|
||||
|
||||
pub use client::{DesktopClient, RemoteTarget};
|
||||
pub use transport::{ServerConfig, ServerRuntime};
|
||||
|
||||
@@ -14,7 +14,7 @@ use tokio::io::{AsyncBufReadExt as _, AsyncWrite, AsyncWriteExt as _, BufReader}
|
||||
|
||||
use crate::auth::KeyMaterial;
|
||||
use crate::host::{ApplicationHost, ApplicationSession};
|
||||
use crate::protocol::{Command, PROTOCOL_VERSION, Request, SUBSYSTEM, ServerMessage};
|
||||
use crate::protocol::{Request, SUBSYSTEM, ServerMessage};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerConfig {
|
||||
@@ -249,10 +249,10 @@ impl Handler for ConnectionHandler {
|
||||
session.channel_failure(channel)?;
|
||||
return Ok(());
|
||||
};
|
||||
let application = self.host.session()?;
|
||||
let host = self.host.clone();
|
||||
session.channel_success(channel.id())?;
|
||||
tokio::spawn(async move {
|
||||
let _ = run_terminal_session(channel, application).await;
|
||||
let _ = run_terminal_session(channel, host).await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -322,77 +322,78 @@ async fn write_message(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_terminal_session(
|
||||
mut channel: Channel<Msg>,
|
||||
mut application: ApplicationSession,
|
||||
) -> Result<()> {
|
||||
let locale = application.locale().to_owned();
|
||||
let hello = application.handle(Request {
|
||||
id: "terminal-hello".into(),
|
||||
command: Command::Hello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
},
|
||||
});
|
||||
if matches!(hello, ServerMessage::Error { .. }) {
|
||||
return Err(anyhow!("terminal protocol negotiation failed"));
|
||||
}
|
||||
let projects = application.handle(Request {
|
||||
id: "terminal-projects".into(),
|
||||
command: Command::ListProjects,
|
||||
});
|
||||
let project_names = match projects {
|
||||
ServerMessage::Response { result, .. } => result
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|project| project.get("name").and_then(|name| name.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n • "),
|
||||
_ => String::new(),
|
||||
};
|
||||
let banner = terminal_banner(&locale, &project_names);
|
||||
channel.data(banner.as_bytes()).await?;
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(250));
|
||||
async fn run_terminal_session(mut channel: Channel<Msg>, host: ApplicationHost) -> Result<()> {
|
||||
let mut app = crate::tui::TuiApp::new(host, true)?;
|
||||
let mut decoder = crate::tui::InputDecoder::default();
|
||||
let (mut width, mut height) = (80_u16, 24_u16);
|
||||
let mut dirty = true;
|
||||
let mut restored = false;
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(100));
|
||||
loop {
|
||||
tokio::select! {
|
||||
message = channel.wait() => match message {
|
||||
Some(russh::ChannelMsg::Data { data }) if data.iter().any(|byte| matches!(byte, b'q' | 3)) => {
|
||||
channel.close().await?;
|
||||
break;
|
||||
Some(russh::ChannelMsg::Data { data }) => {
|
||||
for input in decoder.push(&data) { app.handle_input(input)?; }
|
||||
dirty = true;
|
||||
}
|
||||
Some(russh::ChannelMsg::RequestPty { col_width, row_height, .. })
|
||||
| Some(russh::ChannelMsg::WindowChange { col_width, row_height, .. }) => {
|
||||
width = u16::try_from(col_width).unwrap_or(u16::MAX).max(20);
|
||||
height = u16::try_from(row_height).unwrap_or(u16::MAX).max(6);
|
||||
dirty = true;
|
||||
}
|
||||
None | Some(russh::ChannelMsg::Close) => break,
|
||||
_ => {}
|
||||
},
|
||||
_ = interval.tick() => {
|
||||
let updates = application.pending();
|
||||
if !updates.is_empty() {
|
||||
let status = format!("\r\n[{} update{}]\r\n", updates.len(), if updates.len() == 1 { "" } else { "s" });
|
||||
channel.data(status.as_bytes()).await?;
|
||||
}
|
||||
for input in decoder.flush() { app.handle_input(input)?; }
|
||||
app.poll()?;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
if dirty {
|
||||
channel
|
||||
.data(&crate::tui::render_ansi(&mut app, width, height)[..])
|
||||
.await?;
|
||||
dirty = false;
|
||||
}
|
||||
if app.should_quit() {
|
||||
channel.data(&b"\x1b[0m\x1b[?25h\r\n"[..]).await?;
|
||||
restored = true;
|
||||
channel.close().await?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !restored {
|
||||
let _ = channel.data(&b"\x1b[0m\x1b[?25h\r\n"[..]).await;
|
||||
let _ = channel.close().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn terminal_banner(locale: &str, projects: &str) -> String {
|
||||
let locale = bds_core::i18n::normalize_language(locale);
|
||||
let title = bds_core::i18n::translate(locale, "remoteTerminal.serverTitle");
|
||||
let available = bds_core::i18n::translate(locale, "remoteTerminal.availableProjects");
|
||||
let quit = bds_core::i18n::translate(locale, "remoteTerminal.quit");
|
||||
let projects = if projects.is_empty() { "—" } else { projects };
|
||||
format!("\x1b[2J\x1b[H{title}\r\n\r\n{available}:\r\n • {projects}\r\n\r\n{quit}\r\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::ClientKeyMaterial;
|
||||
use crate::client::{DesktopClient, RemoteTarget};
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine;
|
||||
use russh::ChannelMsg;
|
||||
use russh::client;
|
||||
use russh::keys::key::PrivateKeyWithHashAlg;
|
||||
use russh::keys::{PublicKey, load_secret_key};
|
||||
use std::fs;
|
||||
use std::thread;
|
||||
|
||||
struct AcceptHostKey;
|
||||
|
||||
impl client::Handler for AcceptHostKey {
|
||||
type Error = anyhow::Error;
|
||||
async fn check_server_key(&mut self, _key: &PublicKey) -> Result<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_loopback_and_external_binding_requires_an_explicit_value() {
|
||||
let config = ServerConfig::local("db".into(), "data".into());
|
||||
@@ -400,15 +401,6 @@ mod tests {
|
||||
assert_eq!(config.port, 2222);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_banner_uses_the_server_locale_and_only_terminal_control_bytes() {
|
||||
let banner = terminal_banner("de", "Blog");
|
||||
assert!(banner.starts_with("\x1b[2J\x1b[H"));
|
||||
assert!(banner.contains("RuDS-Serversitzung"));
|
||||
assert!(banner.contains("Blog"));
|
||||
assert!(!banner.contains('{'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_ssh_authentication_revocation_reconnect_events_and_shutdown() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -512,6 +504,112 @@ mod tests {
|
||||
first.disconnect().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_ssh_pty_renders_resizes_exits_cleanly_and_reconnects() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server_data = root.path().join("server");
|
||||
let client_data = root.path().join("client");
|
||||
fs::create_dir_all(&server_data).unwrap();
|
||||
let database_path = server_data.join("bds.db");
|
||||
let db = Database::open(&database_path).unwrap();
|
||||
db.migrate().unwrap();
|
||||
let project_dir = root.path().join("blog");
|
||||
let project = engine::project::create_project(
|
||||
db.conn(),
|
||||
"PTY Blog",
|
||||
Some(project_dir.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
engine::project::set_active_project(db.conn(), &project.id).unwrap();
|
||||
let runtime = ServerRuntime::start(ServerConfig {
|
||||
database_path,
|
||||
data_root: server_data.clone(),
|
||||
bind: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port: 0,
|
||||
mcp_port: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let identity = ClientKeyMaterial::ensure(&client_data).unwrap();
|
||||
fs::write(
|
||||
&runtime.key_material().authorized_keys_path,
|
||||
fs::read_to_string(&identity.public_key_path).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tokio = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
for _ in 0..2 {
|
||||
tokio.block_on(async {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(Duration::from_secs(5)),
|
||||
..Default::default()
|
||||
});
|
||||
let mut ssh = client::connect(config, runtime.address(), AcceptHostKey)
|
||||
.await
|
||||
.unwrap();
|
||||
let private_key = load_secret_key(&identity.private_key_path, None).unwrap();
|
||||
let hash = ssh.best_supported_rsa_hash().await.unwrap().flatten();
|
||||
assert!(
|
||||
ssh.authenticate_publickey(
|
||||
"author",
|
||||
PrivateKeyWithHashAlg::new(Arc::new(private_key), hash)
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let mut channel = ssh.channel_open_session().await.unwrap();
|
||||
channel
|
||||
.request_pty(true, "xterm-256color", 72, 18, 0, 0, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
channel.request_shell(true).await.unwrap();
|
||||
let first = tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
if let Some(ChannelMsg::Data { data }) = channel.wait().await
|
||||
&& data.starts_with(b"\x1b[?25l\x1b[2J\x1b[H")
|
||||
{
|
||||
break data;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
first
|
||||
.windows(b"PTY Blog".len())
|
||||
.any(|window| window == b"PTY Blog")
|
||||
);
|
||||
channel.window_change(100, 30, 0, 0).await.unwrap();
|
||||
channel.data(&[17_u8][..]).await.unwrap();
|
||||
let restored = tokio::time::timeout(Duration::from_secs(3), async {
|
||||
let mut output = Vec::new();
|
||||
while let Some(message) = channel.wait().await {
|
||||
match message {
|
||||
ChannelMsg::Data { data } => output.extend_from_slice(&data),
|
||||
ChannelMsg::Close | ChannelMsg::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
output
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
restored
|
||||
.windows(b"\x1b[?25h".len())
|
||||
.any(|window| window == b"\x1b[?25h")
|
||||
);
|
||||
ssh.disconnect(russh::Disconnect::ByApplication, "", "en")
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
runtime.stop().unwrap();
|
||||
}
|
||||
|
||||
fn domain_event_count(messages: &[ServerMessage], project_id: &str) -> usize {
|
||||
messages
|
||||
.iter()
|
||||
|
||||
4479
crates/bds-server/src/tui.rs
Normal file
4479
crates/bds-server/src/tui.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -738,3 +738,33 @@ remoteTerminal-quit = q zum Beenden
|
||||
remoteTerminal-serverListening = RuDS-SSH-Server hört auf { $address }
|
||||
remoteTerminal-authorizedKeys = Autorisierte Schlüssel: { $path }
|
||||
remoteTerminal-headlessFallback = Keine grafische Sitzung verfügbar; die Terminaloberfläche wird gestartet.
|
||||
tui-viewPosts = Beiträge
|
||||
tui-viewMedia = Medien
|
||||
tui-viewTemplates = Vorlagen
|
||||
tui-viewScripts = Skripte
|
||||
tui-viewTags = Tags
|
||||
tui-viewSettings = Einstellungen
|
||||
tui-viewGit = Git
|
||||
tui-help = 1 Beiträge · 2 Medien · 3 Vorlagen · 4 Skripte · 5 Tags · 6 Einstellungen · 7 Git
|
||||
|
||||
Eingabe öffnet · n erstellt · / filtert · : Befehle · p Projekte · o Ordner
|
||||
Strg+S speichert · Strg+P veröffentlicht · Strg+E Vorschau · Strg+G fragt KI
|
||||
tui-status = { $transport } · { $locale } · q beendet
|
||||
tui-local = lokal
|
||||
tui-remote = SSH
|
||||
tui-unsavedTitle = Ungespeicherte Änderungen
|
||||
tui-unsavedPrompt = Ungespeicherte Änderungen verwerfen und beenden? j/n
|
||||
tui-noProject = Kein Projekt — o öffnet einen Ordner
|
||||
tui-noMatchingItems = Keine passenden Einträge
|
||||
tui-noTags = Noch keine Tags
|
||||
tui-workingTreeClean = Arbeitsverzeichnis sauber
|
||||
tui-applyCancel = Eingabe wendet alles an · Esc bricht ab
|
||||
tui-settingUiLanguage = Oberflächensprache
|
||||
tui-settingRuntime = Skript-Laufzeit
|
||||
tui-settingTransferMode = Übertragungsmodus
|
||||
tui-settingDatabase = Datenbank
|
||||
tui-settingAutomaticRebuild = Automatischer Neuaufbau
|
||||
tui-settingTheme = Erscheinungsbild
|
||||
tui-settingContentWidth = Inhaltsbreite
|
||||
tui-categoryEditing = Kategorien bearbeiten
|
||||
tui-categoryEditingGuiOnly = Kategorien können weiterhin in der Desktop-Anwendung hinzugefügt, bearbeitet und entfernt werden
|
||||
|
||||
@@ -738,3 +738,33 @@ remoteTerminal-quit = q to quit
|
||||
remoteTerminal-serverListening = RuDS SSH server listening on { $address }
|
||||
remoteTerminal-authorizedKeys = Authorized keys: { $path }
|
||||
remoteTerminal-headlessFallback = No graphical session is available; starting the terminal UI.
|
||||
tui-viewPosts = Posts
|
||||
tui-viewMedia = Media
|
||||
tui-viewTemplates = Templates
|
||||
tui-viewScripts = Scripts
|
||||
tui-viewTags = Tags
|
||||
tui-viewSettings = Settings
|
||||
tui-viewGit = Git
|
||||
tui-help = 1 Posts · 2 Media · 3 Templates · 4 Scripts · 5 Tags · 6 Settings · 7 Git
|
||||
|
||||
Enter opens · n creates · / filters · : commands · p projects · o folder
|
||||
Ctrl+S saves · Ctrl+P publishes · Ctrl+E previews · Ctrl+G asks AI
|
||||
tui-status = { $transport } · { $locale } · q quit
|
||||
tui-local = local
|
||||
tui-remote = SSH
|
||||
tui-unsavedTitle = Unsaved changes
|
||||
tui-unsavedPrompt = Discard unsaved changes and exit? y/n
|
||||
tui-noProject = No project — press o to open a folder
|
||||
tui-noMatchingItems = No matching items
|
||||
tui-noTags = No tags yet
|
||||
tui-workingTreeClean = Working tree clean
|
||||
tui-applyCancel = Enter applies all · Esc cancels
|
||||
tui-settingUiLanguage = Interface Language
|
||||
tui-settingRuntime = Scripting Runtime
|
||||
tui-settingTransferMode = Transfer Mode
|
||||
tui-settingDatabase = Database
|
||||
tui-settingAutomaticRebuild = Automatic Rebuild
|
||||
tui-settingTheme = Theme
|
||||
tui-settingContentWidth = Content Width
|
||||
tui-categoryEditing = Category Editing
|
||||
tui-categoryEditingGuiOnly = Category add, edit, and removal remain in the desktop application
|
||||
|
||||
@@ -738,3 +738,33 @@ remoteTerminal-quit = q para salir
|
||||
remoteTerminal-serverListening = El servidor SSH de RuDS escucha en { $address }
|
||||
remoteTerminal-authorizedKeys = Claves autorizadas: { $path }
|
||||
remoteTerminal-headlessFallback = No hay una sesión gráfica disponible; se iniciará la interfaz de terminal.
|
||||
tui-viewPosts = Publicaciones
|
||||
tui-viewMedia = Medios
|
||||
tui-viewTemplates = Plantillas
|
||||
tui-viewScripts = Scripts
|
||||
tui-viewTags = Etiquetas
|
||||
tui-viewSettings = Ajustes
|
||||
tui-viewGit = Git
|
||||
tui-help = 1 Publicaciones · 2 Medios · 3 Plantillas · 4 Scripts · 5 Etiquetas · 6 Ajustes · 7 Git
|
||||
|
||||
Intro abre · n crea · / filtra · : comandos · p proyectos · o carpeta
|
||||
Ctrl+S guarda · Ctrl+P publica · Ctrl+E vista previa · Ctrl+G pregunta a la IA
|
||||
tui-status = { $transport } · { $locale } · q sale
|
||||
tui-local = local
|
||||
tui-remote = SSH
|
||||
tui-unsavedTitle = Cambios sin guardar
|
||||
tui-unsavedPrompt = ¿Descartar los cambios y salir? s/n
|
||||
tui-noProject = Ningún proyecto — o abre una carpeta
|
||||
tui-noMatchingItems = No hay elementos coincidentes
|
||||
tui-noTags = Aún no hay etiquetas
|
||||
tui-workingTreeClean = Árbol de trabajo limpio
|
||||
tui-applyCancel = Intro aplica todo · Esc cancela
|
||||
tui-settingUiLanguage = Idioma de la interfaz
|
||||
tui-settingRuntime = Motor de scripts
|
||||
tui-settingTransferMode = Modo de transferencia
|
||||
tui-settingDatabase = Base de datos
|
||||
tui-settingAutomaticRebuild = Reconstrucción automática
|
||||
tui-settingTheme = Tema
|
||||
tui-settingContentWidth = Ancho del contenido
|
||||
tui-categoryEditing = Edición de categorías
|
||||
tui-categoryEditingGuiOnly = La creación, edición y eliminación de categorías permanecen en la aplicación de escritorio
|
||||
|
||||
@@ -738,3 +738,33 @@ remoteTerminal-quit = q pour quitter
|
||||
remoteTerminal-serverListening = Le serveur SSH RuDS écoute sur { $address }
|
||||
remoteTerminal-authorizedKeys = Clés autorisées : { $path }
|
||||
remoteTerminal-headlessFallback = Aucune session graphique n’est disponible ; démarrage de l’interface terminal.
|
||||
tui-viewPosts = Articles
|
||||
tui-viewMedia = Médias
|
||||
tui-viewTemplates = Modèles
|
||||
tui-viewScripts = Scripts
|
||||
tui-viewTags = Tags
|
||||
tui-viewSettings = Réglages
|
||||
tui-viewGit = Git
|
||||
tui-help = 1 Articles · 2 Médias · 3 Modèles · 4 Scripts · 5 Tags · 6 Réglages · 7 Git
|
||||
|
||||
Entrée ouvre · n crée · / filtre · : commandes · p projets · o dossier
|
||||
Ctrl+S enregistre · Ctrl+P publie · Ctrl+E aperçu · Ctrl+G demande à l’IA
|
||||
tui-status = { $transport } · { $locale } · q quitte
|
||||
tui-local = local
|
||||
tui-remote = SSH
|
||||
tui-unsavedTitle = Modifications non enregistrées
|
||||
tui-unsavedPrompt = Abandonner les modifications et quitter ? o/n
|
||||
tui-noProject = Aucun projet — o ouvre un dossier
|
||||
tui-noMatchingItems = Aucun élément correspondant
|
||||
tui-noTags = Aucun tag
|
||||
tui-workingTreeClean = Arbre de travail propre
|
||||
tui-applyCancel = Entrée applique tout · Échap annule
|
||||
tui-settingUiLanguage = Langue de l’interface
|
||||
tui-settingRuntime = Moteur de scripts
|
||||
tui-settingTransferMode = Mode de transfert
|
||||
tui-settingDatabase = Base de données
|
||||
tui-settingAutomaticRebuild = Reconstruction automatique
|
||||
tui-settingTheme = Thème
|
||||
tui-settingContentWidth = Largeur du contenu
|
||||
tui-categoryEditing = Modification des catégories
|
||||
tui-categoryEditingGuiOnly = L’ajout, la modification et la suppression de catégories restent dans l’application de bureau
|
||||
|
||||
@@ -738,3 +738,33 @@ remoteTerminal-quit = q per uscire
|
||||
remoteTerminal-serverListening = Il server SSH RuDS è in ascolto su { $address }
|
||||
remoteTerminal-authorizedKeys = Chiavi autorizzate: { $path }
|
||||
remoteTerminal-headlessFallback = Non è disponibile una sessione grafica; avvio dell’interfaccia terminale.
|
||||
tui-viewPosts = Articoli
|
||||
tui-viewMedia = Media
|
||||
tui-viewTemplates = Modelli
|
||||
tui-viewScripts = Script
|
||||
tui-viewTags = Tag
|
||||
tui-viewSettings = Impostazioni
|
||||
tui-viewGit = Git
|
||||
tui-help = 1 Articoli · 2 Media · 3 Modelli · 4 Script · 5 Tag · 6 Impostazioni · 7 Git
|
||||
|
||||
Invio apre · n crea · / filtra · : comandi · p progetti · o cartella
|
||||
Ctrl+S salva · Ctrl+P pubblica · Ctrl+E anteprima · Ctrl+G chiede all’IA
|
||||
tui-status = { $transport } · { $locale } · q esce
|
||||
tui-local = locale
|
||||
tui-remote = SSH
|
||||
tui-unsavedTitle = Modifiche non salvate
|
||||
tui-unsavedPrompt = Scartare le modifiche e uscire? s/n
|
||||
tui-noProject = Nessun progetto — o apre una cartella
|
||||
tui-noMatchingItems = Nessun elemento corrispondente
|
||||
tui-noTags = Nessun tag
|
||||
tui-workingTreeClean = Area di lavoro pulita
|
||||
tui-applyCancel = Invio applica tutto · Esc annulla
|
||||
tui-settingUiLanguage = Lingua dell’interfaccia
|
||||
tui-settingRuntime = Runtime degli script
|
||||
tui-settingTransferMode = Modalità di trasferimento
|
||||
tui-settingDatabase = Database
|
||||
tui-settingAutomaticRebuild = Ricostruzione automatica
|
||||
tui-settingTheme = Tema
|
||||
tui-settingContentWidth = Larghezza del contenuto
|
||||
tui-categoryEditing = Modifica delle categorie
|
||||
tui-categoryEditingGuiOnly = L’aggiunta, la modifica e la rimozione delle categorie restano nell’applicazione desktop
|
||||
|
||||
Reference in New Issue
Block a user