Snapshot-Explorer: Dateien zurueckholen, im Terminal und im Browser
Snapshots anzulegen half bisher nur halb - an die Daten darin kam man nur
ueber einen Rollback. Dafuer jetzt zwei Werkzeuge auf gemeinsamer Basis.
snapfs.py bindet einen Snapshot schreibgeschuetzt ein und liefert einen
gewoehnlichen Pfad; die Oberflaechen wissen dadurch nichts ueber Storages:
rbd (Ceph) rbd map pool/image@snap, bei nicht unterstuetzten
Image-Features faellt es auf rbd-nbd zurueck
zfspool Container ueber .zfs/snapshot, VMs ueber einen Klon
lvmthin/lvm die Snapshot-LV snap_<volume>_<snapname> aktivieren
dir/nfs/cifs qemu-nbd --load-snapshot (nur qcow2)
Gemountet wird mit ro,noload bzw. ro,norecovery,nouuid - Snapshots
laufender Gaeste haben fast immer ein unsauberes Journal. Alles Angelegte
steht in /run/pvesnap/explorer.json und laesst sich nach einem Absturz mit
"--cleanup" wieder abraeumen.
pvesnap-explorer: zwei Fenster wie im Midnight Commander, links der
Snapshot, rechts der lokale Rechner. Markieren, F5, Fortschrittsbalken,
ESC bricht ab, vorhandene Dateien werden abgefragt. In den Snapshot hinein
kann nicht kopiert werden. Geraetedateien und Sockets werden
uebersprungen, symbolische Verweise bleiben Verweise.
pvesnap-web: derselbe Inhalt im Browser, auch vom anderen Rechner.
Einzelne Dateien direkt, Verzeichnisse und Mehrfachauswahl als ZIP, das im
Strom erzeugt wird - ohne Zwischendatei auf der Platte. Zugang nur mit dem
beim Start ausgegebenen Schluessel; Pfade ausserhalb des Snapshots werden
abgewiesen; es wird ausschliesslich gelesen.
Nebenbei: die curses-Bausteine sind aus tui.py nach curses_util.py
gewandert, damit Editor und Explorer sie teilen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1be13ad294
commit
f37a942fea
+45
-205
@@ -9,6 +9,9 @@ import curses
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_OK, C_SEL, C_WARN,
|
||||
choose, confirm, fill, fill_row, init_colors, is_enter,
|
||||
is_escape, keybar, message, prompt, put, read_key)
|
||||
from .config import (WEEKDAY_NAMES, Config, ConfigError, Group, clone_group,
|
||||
load_config, parse_time_of_day, save_config)
|
||||
from .naming import effective_slug
|
||||
@@ -32,8 +35,6 @@ TYPE_CHOICES = [
|
||||
(["lxc"], "nur Container (LXC)"),
|
||||
]
|
||||
|
||||
C_HEADER, C_FOOTER, C_SEL, C_WARN, C_OK, C_DIM = 1, 2, 3, 4, 5, 6
|
||||
|
||||
SERVICE = "pvesnap.service"
|
||||
|
||||
# Tastenleiste der Gruppenliste: (Taste, lange Beschriftung, kurze Beschriftung)
|
||||
@@ -93,80 +94,6 @@ def service_text():
|
||||
"activating": "Dienst: startet"}.get(state, "Dienst: %s" % state)
|
||||
|
||||
|
||||
def _keybar(width, items):
|
||||
"""Tastenleiste als Liste von Zeilen, passend zur Breite des Terminals.
|
||||
|
||||
Reicht der Platz nicht fuer eine Zeile, wird auf zwei Zeilen umgebrochen;
|
||||
bei ganz schmalen Terminals bleiben nur noch die Tasten uebrig.
|
||||
"""
|
||||
def join(entries, index):
|
||||
return " | ".join("%s %s" % (entry[0], entry[index]) for entry in entries)
|
||||
|
||||
long_text = join(items, 1)
|
||||
if len(long_text) + 2 <= width:
|
||||
return [long_text]
|
||||
|
||||
half = (len(items) + 1) // 2
|
||||
first, second = join(items[:half], 1), join(items[half:], 1)
|
||||
if max(len(first), len(second)) + 2 <= width:
|
||||
return [first, second]
|
||||
|
||||
short_text = join(items, 2)
|
||||
if len(short_text) + 2 <= width:
|
||||
return [short_text]
|
||||
|
||||
first, second = join(items[:half], 2), join(items[half:], 2)
|
||||
if max(len(first), len(second)) + 2 <= width:
|
||||
return [first, second]
|
||||
return [" ".join(entry[0] for entry in items)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zeichen-Helfer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _put(win, y, x, text, attr=0):
|
||||
"""addstr, das bei zu kleinem Terminal nicht abstuerzt."""
|
||||
height, width = win.getmaxyx()
|
||||
if y < 0 or y >= height or x >= width:
|
||||
return
|
||||
try:
|
||||
win.addstr(y, x, str(text)[: max(0, width - x - 1)], attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
def _fill(win, y, text, attr):
|
||||
height, width = win.getmaxyx()
|
||||
if y < 0 or y >= height:
|
||||
return
|
||||
try:
|
||||
win.addstr(y, 0, str(text).ljust(width - 1)[: width - 1], attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
def _read_key(win):
|
||||
try:
|
||||
return win.get_wch()
|
||||
except curses.error:
|
||||
return None
|
||||
except KeyboardInterrupt:
|
||||
return "\x1b"
|
||||
|
||||
|
||||
def _is_enter(key):
|
||||
return key in ("\n", "\r", curses.KEY_ENTER)
|
||||
|
||||
|
||||
def _is_escape(key):
|
||||
return key == "\x1b"
|
||||
|
||||
|
||||
def _is_backspace(key):
|
||||
return key in ("\x7f", "\b", "\x08", curses.KEY_BACKSPACE)
|
||||
|
||||
|
||||
class Editor:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
@@ -206,99 +133,25 @@ class Editor:
|
||||
def _frame(self, win, title, subtitle, keys):
|
||||
win.erase()
|
||||
height, width = win.getmaxyx()
|
||||
_fill(win, 0, " pvesnap - %s" % title, curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||
fill(win, 0, " pvesnap - %s" % title, curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||
marker = "*" if self.dirty else " "
|
||||
_fill(win, 1, " %s%s" % (marker, subtitle), curses.color_pair(C_DIM))
|
||||
fill(win, 1, " %s%s" % (marker, subtitle), curses.color_pair(C_DIM))
|
||||
lines = list(keys) if isinstance(keys, (list, tuple)) else [keys]
|
||||
for offset, line in enumerate(reversed(lines)):
|
||||
_fill(win, height - 1 - offset, " " + line, curses.color_pair(C_FOOTER))
|
||||
fill(win, height - 1 - offset, " " + line, curses.color_pair(C_FOOTER))
|
||||
return height, width
|
||||
|
||||
def _message(self, win, text, error=False):
|
||||
height, _ = win.getmaxyx()
|
||||
attr = curses.color_pair(C_WARN if error else C_OK) | curses.A_BOLD
|
||||
_fill(win, height - 2, " " + str(text) + " [Taste druecken]", attr)
|
||||
win.refresh()
|
||||
_read_key(win)
|
||||
return message(win, text, error)
|
||||
|
||||
def _confirm(self, win, question):
|
||||
height, _ = win.getmaxyx()
|
||||
_fill(win, height - 2, " %s [j/n]" % question,
|
||||
curses.color_pair(C_WARN) | curses.A_BOLD)
|
||||
win.refresh()
|
||||
while True:
|
||||
key = _read_key(win)
|
||||
if key in ("j", "J", "y", "Y"):
|
||||
return True
|
||||
if key in ("n", "N") or _is_escape(key):
|
||||
return False
|
||||
return confirm(win, question)
|
||||
|
||||
def _prompt(self, win, label, initial=""):
|
||||
"""Einzeilige Eingabe am unteren Rand. None = abgebrochen."""
|
||||
height, width = win.getmaxyx()
|
||||
buffer = list(str(initial))
|
||||
curses.curs_set(1)
|
||||
try:
|
||||
while True:
|
||||
text = "".join(buffer)
|
||||
prompt = " %s: " % label
|
||||
visible = width - len(prompt) - 2
|
||||
shown = text[-visible:] if visible > 0 and len(text) > visible else text
|
||||
_fill(win, height - 2, prompt + shown, curses.color_pair(C_SEL))
|
||||
try:
|
||||
win.move(height - 2, min(width - 2, len(prompt) + len(shown)))
|
||||
except curses.error:
|
||||
pass
|
||||
win.refresh()
|
||||
|
||||
key = _read_key(win)
|
||||
if key is None:
|
||||
continue
|
||||
if _is_enter(key):
|
||||
return "".join(buffer).strip()
|
||||
if _is_escape(key):
|
||||
return None
|
||||
if _is_backspace(key):
|
||||
if buffer:
|
||||
buffer.pop()
|
||||
continue
|
||||
if key == "\x15": # Strg-U
|
||||
buffer = []
|
||||
continue
|
||||
if isinstance(key, str) and key.isprintable():
|
||||
buffer.append(key)
|
||||
finally:
|
||||
curses.curs_set(0)
|
||||
return prompt(win, label, initial)
|
||||
|
||||
def _choose(self, win, title, entries, current=0):
|
||||
"""Kleines Auswahlfenster. entries: Liste von (wert, beschriftung)."""
|
||||
height, width = win.getmaxyx()
|
||||
box_h = min(len(entries) + 4, height - 2)
|
||||
box_w = min(max([len(title)] + [len(e[1]) for e in entries]) + 8, width - 2)
|
||||
top = max(0, (height - box_h) // 2)
|
||||
left = max(0, (width - box_w) // 2)
|
||||
box = curses.newwin(box_h, box_w, top, left)
|
||||
box.keypad(True)
|
||||
index = max(0, min(current, len(entries) - 1))
|
||||
|
||||
while True:
|
||||
box.erase()
|
||||
box.box()
|
||||
_put(box, 0, 2, " %s " % title, curses.A_BOLD)
|
||||
for row, (_value, label) in enumerate(entries[: box_h - 4]):
|
||||
attr = curses.color_pair(C_SEL) if row == index else 0
|
||||
_put(box, row + 2, 2, " " + label.ljust(box_w - 5), attr)
|
||||
box.refresh()
|
||||
|
||||
key = _read_key(box)
|
||||
if key in (curses.KEY_UP, "k"):
|
||||
index = (index - 1) % len(entries)
|
||||
elif key in (curses.KEY_DOWN, "j"):
|
||||
index = (index + 1) % len(entries)
|
||||
elif _is_enter(key):
|
||||
return entries[index][0]
|
||||
elif _is_escape(key) or key == "q":
|
||||
return None
|
||||
return choose(win, title, entries, current)
|
||||
|
||||
# -- Hauptschleife ----------------------------------------------------
|
||||
|
||||
@@ -306,17 +159,7 @@ class Editor:
|
||||
curses.curs_set(0)
|
||||
if hasattr(curses, "set_escdelay"):
|
||||
curses.set_escdelay(25)
|
||||
try:
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(C_HEADER, curses.COLOR_WHITE, curses.COLOR_BLUE)
|
||||
curses.init_pair(C_FOOTER, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
||||
curses.init_pair(C_SEL, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
||||
curses.init_pair(C_WARN, curses.COLOR_WHITE, curses.COLOR_RED)
|
||||
curses.init_pair(C_OK, curses.COLOR_BLACK, curses.COLOR_GREEN)
|
||||
curses.init_pair(C_DIM, -1, -1)
|
||||
except curses.error:
|
||||
pass
|
||||
init_colors()
|
||||
stdscr.keypad(True)
|
||||
self.screen_groups(stdscr)
|
||||
|
||||
@@ -336,16 +179,16 @@ class Editor:
|
||||
else "…" + self.path[-(room - 1):])
|
||||
height, width = self._frame(
|
||||
win, "Gruppen", "Datei: %s [%s]" % (path_text, status),
|
||||
_keybar(width_now, GROUP_KEYS))
|
||||
keybar(width_now, GROUP_KEYS))
|
||||
|
||||
_put(win, 3, 2, "Gruppe".ljust(18) + "Aktiv " + "Zeitplan".ljust(30)
|
||||
put(win, 3, 2, "Gruppe".ljust(18) + "Aktiv " + "Zeitplan".ljust(30)
|
||||
+ "Behalte".ljust(20) + "Auswahl", curses.A_BOLD)
|
||||
_put(win, 4, 2, "-" * max(0, width - 4), curses.color_pair(C_DIM))
|
||||
put(win, 4, 2, "-" * max(0, width - 4), curses.color_pair(C_DIM))
|
||||
|
||||
if not groups:
|
||||
_put(win, 6, 4, "Noch keine Gruppe angelegt - mit 'n' eine erste anlegen.")
|
||||
_put(win, 8, 4, "Beispiel: 'stuendlich' alle 1h, 24 Snapshots behalten;")
|
||||
_put(win, 9, 4, " 'taeglich' um 02:30, 14 Tage Vorhaltezeit.")
|
||||
put(win, 6, 4, "Noch keine Gruppe angelegt - mit 'n' eine erste anlegen.")
|
||||
put(win, 8, 4, "Beispiel: 'stuendlich' alle 1h, 24 Snapshots behalten;")
|
||||
put(win, 9, 4, " 'taeglich' um 02:30, 14 Tage Vorhaltezeit.")
|
||||
visible = max(1, height - 8)
|
||||
start = max(0, min(index - visible // 2, max(0, len(groups) - visible)))
|
||||
|
||||
@@ -360,15 +203,15 @@ class Editor:
|
||||
truncate(describe(group), 29).ljust(30),
|
||||
truncate(keep, 19).ljust(20),
|
||||
truncate(self._selection_text(group), max(4, width - 80))))
|
||||
_fill_row(win, 5 + row, 2, line, width, attr)
|
||||
fill_row(win, 5 + row, 2, line, width, attr)
|
||||
|
||||
problems = self.config.validate()
|
||||
if problems:
|
||||
_fill(win, height - 2, " %d Hinweis(e) - 'p' zeigt Details"
|
||||
fill(win, height - 2, " %d Hinweis(e) - 'p' zeigt Details"
|
||||
% len(problems), curses.color_pair(C_WARN))
|
||||
|
||||
win.refresh()
|
||||
key = _read_key(win)
|
||||
key = read_key(win)
|
||||
|
||||
if key in (curses.KEY_UP, "k"):
|
||||
index -= 1
|
||||
@@ -378,7 +221,7 @@ class Editor:
|
||||
index = 0
|
||||
elif key == curses.KEY_END:
|
||||
index = len(groups) - 1
|
||||
elif _is_enter(key) and groups:
|
||||
elif is_enter(key) and groups:
|
||||
self.screen_group(win, groups[index])
|
||||
elif key == "n":
|
||||
self._new_group(win)
|
||||
@@ -405,7 +248,7 @@ class Editor:
|
||||
self._save(win)
|
||||
elif key in ("r", "R"):
|
||||
self._reload_service(win)
|
||||
elif key in ("q", "Q") or _is_escape(key):
|
||||
elif key in ("q", "Q") or is_escape(key):
|
||||
if self.dirty and not self._confirm(win, "Ungespeicherte Aenderungen verwerfen?"):
|
||||
continue
|
||||
return
|
||||
@@ -441,11 +284,11 @@ class Editor:
|
||||
height, width = self._frame(win, "Hinweise zur Konfiguration", self.path,
|
||||
"beliebige Taste = zurueck")
|
||||
if not problems:
|
||||
_put(win, 4, 4, "Alles in Ordnung.", curses.color_pair(C_OK))
|
||||
put(win, 4, 4, "Alles in Ordnung.", curses.color_pair(C_OK))
|
||||
for row, problem in enumerate(problems[: height - 6]):
|
||||
_put(win, 4 + row, 4, truncate("- " + problem, width - 6))
|
||||
put(win, 4 + row, 4, truncate("- " + problem, width - 6))
|
||||
win.refresh()
|
||||
_read_key(win)
|
||||
read_key(win)
|
||||
|
||||
def _save(self, win, offer_reload=True):
|
||||
problems = self.config.validate()
|
||||
@@ -511,7 +354,7 @@ class Editor:
|
||||
|
||||
def _run_service_action(self, win, action, past_tense):
|
||||
height, _ = win.getmaxyx()
|
||||
_fill(win, height - 2, " systemctl %s %s ..." % (action, SERVICE),
|
||||
fill(win, height - 2, " systemctl %s %s ..." % (action, SERVICE),
|
||||
curses.color_pair(C_SEL))
|
||||
win.refresh()
|
||||
code, output = _systemctl(action, SERVICE)
|
||||
@@ -553,16 +396,16 @@ class Editor:
|
||||
break
|
||||
kind, label, value, _handler = fields[position]
|
||||
if kind == "-":
|
||||
_put(win, 3 + row, 2, "-- %s " % label + "-" * max(0, width - len(label) - 8),
|
||||
put(win, 3 + row, 2, "-- %s " % label + "-" * max(0, width - len(label) - 8),
|
||||
curses.color_pair(C_DIM) | curses.A_BOLD)
|
||||
continue
|
||||
attr = curses.color_pair(C_SEL) if position == index else 0
|
||||
_fill_row(win, 3 + row, 2,
|
||||
fill_row(win, 3 + row, 2,
|
||||
truncate(label, 30).ljust(32) + truncate(value, max(4, width - 40)),
|
||||
width, attr)
|
||||
|
||||
win.refresh()
|
||||
key = _read_key(win)
|
||||
key = read_key(win)
|
||||
|
||||
if key in (curses.KEY_UP, "k"):
|
||||
index = self._step(fields, index, -1)
|
||||
@@ -572,14 +415,14 @@ class Editor:
|
||||
index = self._step(fields, min(index + 10, len(fields) - 1), +1, land=True)
|
||||
elif key == curses.KEY_PPAGE:
|
||||
index = self._step(fields, max(index - 10, 0), -1, land=True)
|
||||
elif _is_enter(key) or key == " ":
|
||||
elif is_enter(key) or key == " ":
|
||||
handler = fields[index][3]
|
||||
if handler:
|
||||
handler(win)
|
||||
self.dirty = True
|
||||
elif key == "v":
|
||||
self._pick_vms(win, group)
|
||||
elif key in ("q", "Q") or _is_escape(key):
|
||||
elif key in ("q", "Q") or is_escape(key):
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
@@ -808,7 +651,7 @@ class Editor:
|
||||
"Leer Auswaehlen | a Alle | n Keine | i Umkehren | / Filter | "
|
||||
"Enter Uebernehmen | q Abbruch")
|
||||
|
||||
_put(win, 3, 2, " VMID Typ Name".ljust(46) + "Node".ljust(14)
|
||||
put(win, 3, 2, " VMID Typ Name".ljust(46) + "Node".ljust(14)
|
||||
+ "Status".ljust(10) + "Tags", curses.A_BOLD)
|
||||
visible = max(1, height - 6)
|
||||
if index < top:
|
||||
@@ -831,10 +674,10 @@ class Editor:
|
||||
truncate(guest.node, 12).ljust(14),
|
||||
truncate(guest.status, 9).ljust(10),
|
||||
truncate(",".join(guest.tags) or "-", 20)))
|
||||
_fill_row(win, 4 + row, 2, line, width, attr)
|
||||
fill_row(win, 4 + row, 2, line, width, attr)
|
||||
|
||||
win.refresh()
|
||||
key = _read_key(win)
|
||||
key = read_key(win)
|
||||
|
||||
if key in (curses.KEY_UP, "k"):
|
||||
index -= 1
|
||||
@@ -872,11 +715,11 @@ class Editor:
|
||||
elif key == "r":
|
||||
self.guests(refresh=True)
|
||||
guests = self._guests
|
||||
elif _is_enter(key):
|
||||
elif is_enter(key):
|
||||
group.vmids = sorted(chosen)
|
||||
self.dirty = True
|
||||
return
|
||||
elif key in ("q", "Q") or _is_escape(key):
|
||||
elif key in ("q", "Q") or is_escape(key):
|
||||
return
|
||||
index = max(0, min(index, max(0, len(shown) - 1)))
|
||||
|
||||
@@ -923,24 +766,24 @@ class Editor:
|
||||
"Enter Aendern | q zurueck")
|
||||
for row, (label, value, _kind, _key) in enumerate(fields):
|
||||
attr = curses.color_pair(C_SEL) if row == index else 0
|
||||
_fill_row(win, 4 + row, 2,
|
||||
fill_row(win, 4 + row, 2,
|
||||
truncate(label, 32).ljust(34) + truncate(str(value), max(4, width - 42)),
|
||||
width, attr)
|
||||
_put(win, 6 + len(fields), 2,
|
||||
put(win, 6 + len(fields), 2,
|
||||
"Platzhalter der Beschreibung: {group} {vmid} {name} {node} {type} "
|
||||
"{datetime} {date} {time} {keep_time} {keep_count} {schedule}",
|
||||
curses.color_pair(C_DIM))
|
||||
win.refresh()
|
||||
|
||||
key = _read_key(win)
|
||||
key = read_key(win)
|
||||
if key in (curses.KEY_UP, "k"):
|
||||
index -= 1
|
||||
elif key in (curses.KEY_DOWN, "j"):
|
||||
index += 1
|
||||
elif _is_enter(key) or key == " ":
|
||||
elif is_enter(key) or key == " ":
|
||||
label, _value, kind, attribute = fields[index]
|
||||
self._edit_global(win, label, kind, attribute)
|
||||
elif key in ("q", "Q") or _is_escape(key):
|
||||
elif key in ("q", "Q") or is_escape(key):
|
||||
return
|
||||
|
||||
def _edit_global(self, win, label, kind, attribute):
|
||||
@@ -994,7 +837,7 @@ class Editor:
|
||||
height, width = self._frame(win, "Uebersicht: welche VM in welcher Gruppe?",
|
||||
self._inventory_error or "%d Gaeste" % len(guests),
|
||||
"Pfeiltasten Blaettern | r Neu laden | q zurueck")
|
||||
_put(win, 3, 2, "VMID".ljust(8) + "Name".ljust(26) + "Status".ljust(10) + "Gruppen",
|
||||
put(win, 3, 2, "VMID".ljust(8) + "Name".ljust(26) + "Status".ljust(10) + "Gruppen",
|
||||
curses.A_BOLD)
|
||||
rows = []
|
||||
for guest in guests:
|
||||
@@ -1009,7 +852,7 @@ class Editor:
|
||||
break
|
||||
guest, names = rows[position]
|
||||
attr = 0 if names else curses.color_pair(C_DIM) | curses.A_DIM
|
||||
_put(win, 4 + row, 2,
|
||||
put(win, 4 + row, 2,
|
||||
truncate(str(guest.vmid), 7).ljust(8)
|
||||
+ truncate(guest.name or "-", 24).ljust(26)
|
||||
+ truncate(guest.status, 9).ljust(10)
|
||||
@@ -1017,7 +860,7 @@ class Editor:
|
||||
attr)
|
||||
win.refresh()
|
||||
|
||||
key = _read_key(win)
|
||||
key = read_key(win)
|
||||
if key == curses.KEY_DOWN:
|
||||
top += 1
|
||||
elif key == curses.KEY_UP:
|
||||
@@ -1028,7 +871,7 @@ class Editor:
|
||||
top -= visible
|
||||
elif key == "r":
|
||||
guests = self.guests(refresh=True)
|
||||
elif key in ("q", "Q") or _is_escape(key):
|
||||
elif key in ("q", "Q") or is_escape(key):
|
||||
return
|
||||
top = max(0, top)
|
||||
|
||||
@@ -1047,9 +890,6 @@ def _preview_next(group):
|
||||
moment.strftime("%d.%m.%Y %H:%M"), describe(group))
|
||||
|
||||
|
||||
def _fill_row(win, y, x, text, width, attr):
|
||||
_put(win, y, x, str(text).ljust(max(0, width - x - 1))[: max(0, width - x - 1)], attr)
|
||||
|
||||
|
||||
def run_editor(path):
|
||||
editor = Editor(path)
|
||||
|
||||
Reference in New Issue
Block a user