NomadNet/nomadnet/ui/textui/Editor.py

1068 lines
40 KiB
Python
Raw Normal View History

import os
import json
import shutil
import urwid
import RNS
import nomadnet
from .ReadlineEdit import ReadlineEdit
from .Browser import Browser
from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox
PREVIEW_DELAY = 0.4
PANE_WEIGHTS = {"files": 0.22, "editor": 0.40, "preview": 0.38}
PANE_ORDER = ["files", "editor", "preview"]
MAX_HIGHLIGHT_CHARS = 50000
MAX_PREVIEW_CHARS = 100000
SNIPPETS = [
("Heading", ">Heading\n"),
("Sub-heading", ">>Sub heading\n"),
("Collapsible", "`+>Section\nbody\n<\n"),
("Bold", "`!bold`!"),
("Italic", "`*italic`*"),
("Underline", "`_underline`_"),
("Foreground col", "`F00ftext`f"),
("Background col", "`B400text`b"),
("Link", "`[label`:/page/index.mu]"),
("Anchor", "`:anchor-name"),
("Anchor link", "`[label`#anchor-name]"),
("Input field", "`<24|name`>"),
("Masked field", "`<!24|name`>"),
("Checkbox", "`<?|name|value`>"),
("Radio", "`<^|name|value`>"),
("Divider", "-\n"),
("Table", "`t\n| A | B |\n| 1 | 2 |\n`t\n"),
("Literal block", "`=\nliteral text\n`=\n"),
]
EDITOR_STYLES = [
("editor_comment", "dark gray", "default", None, "#888", "default"),
("editor_heading", "light cyan,bold", "default", None, "#0cc", "default"),
("editor_tag", "light magenta", "default", None, "#c6c", "default"),
("editor_field", "yellow", "default", None, "#cc0", "default"),
("editor_link", "light green", "default", None, "#0c6", "default"),
("editor_dir", "light blue,bold", "default", None, "#39f", "default"),
("editor_exec", "dark gray", "default", None, "#666", "default"),
("editor_lineno", "dark gray", "default", None, "#555", "default"),
]
def is_executable(path):
try:
return (not RNS.vendor.platformutils.is_windows()) and os.path.isfile(path) and os.access(path, os.X_OK)
except Exception:
return False
def _scan_tag(text, i, n):
# text[i] == "`"; return (end_index, attr)
if i + 1 >= n:
return i + 1, "editor_tag"
c = text[i + 1]
if c == "<":
j = text.find(">", i + 2); return (n if j < 0 else j + 1), "editor_field"
if c == "[":
j = text.find("]", i + 2); return (n if j < 0 else j + 1), "editor_link"
if c == "{":
j = text.find("}", i + 2); return (n if j < 0 else j + 1), "editor_link"
if c in "FB":
if i + 2 < n and text[i + 2] == "T":
return min(i + 9, n), "editor_tag"
return min(i + 5, n), "editor_tag"
if c == ":":
j = i + 2
while j < n and (text[j].isalnum() or text[j] in "_-"):
j += 1
return j, "editor_tag"
return i + 2, "editor_tag"
def highlight_micron(text):
n = len(text)
attrs = [None] * n
i = 0
line_start = True
while i < n:
c = text[i]
if line_start and c == "#":
j = text.find("\n", i); j = n if j < 0 else j
for k in range(i, j): attrs[k] = "editor_comment"
i = j; continue
if line_start and c == ">":
j = i
while j < n and text[j] == ">": j += 1
for k in range(i, j): attrs[k] = "editor_heading"
i = j; line_start = False; continue
if line_start and c == "`" and i + 2 < n and text[i + 1] in "+-" and text[i + 2] == ">":
j = i + 2
while j < n and text[j] == ">": j += 1
for k in range(i, j): attrs[k] = "editor_heading"
i = j; line_start = False; continue
if c == "\n":
i += 1; line_start = True; continue
if c == "\\" and i + 1 < n:
i += 2; line_start = False; continue
if c == "`":
j, attr = _scan_tag(text, i, n)
for k in range(i, min(j, n)): attrs[k] = attr
i = j; line_start = False; continue
i += 1; line_start = False
out = []
run_attr = attrs[0] if n else None
run_len = 0
for a in attrs:
if a == run_attr:
run_len += 1
else:
out.append((run_attr, run_len)); run_attr = a; run_len = 1
if run_len:
out.append((run_attr, run_len))
return out
class MicronEdit(ReadlineEdit):
def __init__(self, *args, **kwargs):
self._hl_text = None
self._hl_attrib = []
super().__init__(*args, **kwargs)
def get_text(self):
text = self.caption + self.edit_text
if text != self._hl_text:
self._hl_text = text
self._hl_attrib = [] if len(text) > MAX_HIGHLIGHT_CHARS else highlight_micron(text)
return text, self._hl_attrib
class GutterEdit(MicronEdit):
# MicronEdit with a wrap-aware line-number gutter on the left
def _gutter_width(self, maxcol):
return max(3, len(str(self.edit_text.count("\n") + 1)) + 2)
def rows(self, size, focus=False):
gw = self._gutter_width(size[0])
return super().rows((max(1, size[0] - gw),), focus)
def get_cursor_coords(self, size):
gw = self._gutter_width(size[0])
coords = super().get_cursor_coords((max(1, size[0] - gw),))
if coords is None:
return None
return coords[0] + gw, coords[1]
def move_cursor_to_coords(self, size, x, y):
gw = self._gutter_width(size[0])
return super().move_cursor_to_coords((max(1, size[0] - gw),), max(0, x - gw), y)
def render(self, size, focus=False):
maxcol = size[0]
gw = self._gutter_width(maxcol)
tw = max(1, maxcol - gw)
edit_canv = super().render((tw,), focus=False)
h = edit_canv.rows()
cheap = len(self.edit_text) > MAX_HIGHLIGHT_CHARS
rows = []
for i, line in enumerate(self.edit_text.split("\n"), 1):
rows.append(str(i).rjust(gw - 1) + " ")
if cheap:
wrapped = max(1, (len(line) + tw - 1) // tw)
else:
wrapped = max(1, urwid.Text(line).rows((tw,)))
rows.extend([""] * (wrapped - 1))
rows = (rows + [""] * h)[:h]
gutter_canv = urwid.Text([("editor_lineno", "\n".join(r.ljust(gw) for r in rows))], wrap="clip").render((gw,))
joined = urwid.CanvasJoin([(gutter_canv, None, False, gw), (edit_canv, None, focus, tw)])
if focus:
coords = super().get_cursor_coords((tw,))
if coords is not None:
joined.cursor = (coords[0] + gw, coords[1])
return joined
def mouse_event(self, size, event, button, x, y, focus):
gw = self._gutter_width(size[0])
if x < gw:
return True
return super().mouse_event((max(1, size[0] - gw),), event, button, x - gw, y, focus)
class FileEntry(urwid.WidgetWrap):
signals = ["click"]
def __init__(self, label, path, depth, executable=False):
self.path = path
self.executable = executable
text = (" " * depth) + "· " + label + (" *" if executable else "")
style = "editor_exec" if executable else None
super().__init__(urwid.AttrMap(urwid.Text(text), style, focus_map="list_focus"))
def selectable(self):
return True
def keypress(self, size, key):
if self._command_map[key] == urwid.ACTIVATE:
self._emit("click")
return None
return key
def mouse_event(self, size, event, button, x, y, focus):
if button == 1 and urwid.util.is_mouse_press(event):
self._emit("click")
return True
return False
class FolderEntry(urwid.WidgetWrap):
signals = ["click"]
def __init__(self, label, path, depth, expanded, glyphs):
self.path = path
icon = glyphs.get("folder_open" if expanded else "folder", "[-]" if expanded else "[+]")
text = (" " * depth) + icon + " " + label + "/"
super().__init__(urwid.AttrMap(urwid.Text(text), "editor_dir", focus_map="list_focus"))
def selectable(self):
return True
def keypress(self, size, key):
if self._command_map[key] == urwid.ACTIVATE:
self._emit("click")
return None
return key
def mouse_event(self, size, event, button, x, y, focus):
if button == 1 and urwid.util.is_mouse_press(event):
self._emit("click")
return True
return False
class PathDialog(urwid.WidgetWrap):
def __init__(self, title, default, on_ok, on_cancel):
self.on_ok = on_ok
self.on_cancel = on_cancel
self.edit = ReadlineEdit(caption="", edit_text=default)
pile = urwid.Pile([
urwid.Text(title),
urwid.Divider(),
urwid.AttrMap(self.edit, "list_off_focus", "list_focus"),
urwid.Divider(),
urwid.Columns([
urwid.Button("OK", on_press=lambda b: self.on_ok(self.edit.edit_text)),
urwid.Button("Cancel", on_press=lambda b: self.on_cancel()),
], dividechars=2),
])
super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title))
def keypress(self, size, key):
if key == "esc":
self.on_cancel(); return None
if key == "enter" and self._w.original_widget.original_widget.focus_position == 2:
self.on_ok(self.edit.edit_text); return None
return super().keypress(size, key)
class InfoDialog(urwid.WidgetWrap):
def __init__(self, title, message, on_ok):
self.on_ok = on_ok
pile = urwid.Pile([
urwid.Text(message),
urwid.Divider(),
urwid.Button("OK", on_press=lambda b: on_ok()),
])
super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title))
def keypress(self, size, key):
if key in ("esc", "enter"):
self.on_ok(); return None
return super().keypress(size, key)
class ConfirmDialog(urwid.WidgetWrap):
def __init__(self, title, message, buttons, on_cancel=None):
self.on_cancel = on_cancel
cols = [urwid.Button(label, on_press=lambda b, c=cb: c()) for label, cb in buttons]
pile = urwid.Pile([urwid.Text(message), urwid.Divider(), urwid.Columns(cols, dividechars=2)])
super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title))
def keypress(self, size, key):
if key == "esc" and self.on_cancel:
self.on_cancel(); return None
return super().keypress(size, key)
class SnippetDialog(urwid.WidgetWrap):
def __init__(self, on_pick, on_cancel):
self.on_cancel = on_cancel
items = [urwid.AttrMap(urwid.Button(name, on_press=lambda b, t=text: on_pick(t)), None, focus_map="list_focus")
for name, text in SNIPPETS]
super().__init__(urwid.LineBox(urwid.ListBox(urwid.SimpleFocusListWalker(items)), title="Insert Micron snippet"))
def keypress(self, size, key):
if key == "esc":
self.on_cancel(); return None
return super().keypress(size, key)
class FindDialog(urwid.WidgetWrap):
def __init__(self, ed, on_close):
self.ed = ed
self.on_close = on_close
self.find_edit = ReadlineEdit(caption="Find: ")
self.repl_edit = ReadlineEdit(caption="Replace: ")
pile = urwid.Pile([
urwid.AttrMap(self.find_edit, "list_off_focus", "list_focus"),
urwid.AttrMap(self.repl_edit, "list_off_focus", "list_focus"),
urwid.Divider(),
urwid.Columns([
urwid.Button("Next", on_press=lambda b: ed.find_next(self.find_edit.edit_text)),
urwid.Button("Replace", on_press=lambda b: ed.replace_one(self.find_edit.edit_text, self.repl_edit.edit_text)),
urwid.Button("All", on_press=lambda b: ed.replace_all(self.find_edit.edit_text, self.repl_edit.edit_text)),
urwid.Button("Close", on_press=lambda b: on_close()),
], dividechars=1),
])
super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title="Find / Replace"))
def keypress(self, size, key):
if key == "esc":
self.on_close(); return None
return super().keypress(size, key)
class PermissionsDialog(urwid.WidgetWrap):
def __init__(self, title, note, content, on_ok, on_cancel):
self.on_ok = on_ok
self.on_cancel = on_cancel
self.edit = ReadlineEdit(caption="", edit_text=content, multiline=True)
pile = urwid.Pile([
urwid.Text(note),
urwid.Divider(),
urwid.BoxAdapter(urwid.AttrMap(urwid.ListBox(urwid.SimpleListWalker([self.edit])), "list_off_focus", "list_focus"), 7),
urwid.Divider(),
urwid.Columns([
urwid.Button("Save", on_press=lambda b: self.on_ok(self.edit.edit_text)),
urwid.Button("Cancel", on_press=lambda b: self.on_cancel()),
], dividechars=2),
])
super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title))
def keypress(self, size, key):
if key == "esc":
self.on_cancel(); return None
return super().keypress(size, key)
class EditorColumns(urwid.Columns):
def keypress(self, size, key):
ed = self.editor_display
if key == "ctrl s":
ed.save_or_saveas(); return None
if key == "ctrl n":
ed.new_file_dialog(); return None
if key == "ctrl f":
ed.find_dialog(); return None
if key == "ctrl g":
ed.snippet_dialog(); return None
if key == "ctrl p":
ed.permissions_dialog(); return None
if key == "f2" and ed.files_focused():
ed.rename_dialog(); return None
if key == "delete" and ed.files_focused():
ed.delete_dialog(); return None
if key == "ctrl t":
ed.toggle_pane("files"); return None
if key == "ctrl b":
ed.toggle_pane("editor"); return None
if key == "ctrl r":
ed.toggle_pane("preview"); return None
if key == "tab":
self.focus_position = (self.focus_position + 1) % len(self.contents); return None
if key == "shift tab":
self.focus_position = (self.focus_position - 1) % len(self.contents); return None
if key == "esc":
nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header"
return None
result = super().keypress(size, key)
if key == "up" and result == "up":
nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header"
return None
return result
class PageEditorShortcuts():
def __init__(self, app):
self.app = app
self.status = urwid.Text("", align=urwid.RIGHT)
base = "[C-n]New [C-s]Save [C-f]Find [C-g]Snippet [C-p]Perms [F2]Rename [Del]Delete [Tab]Pane [C-t/b/r]Toggle [Esc]Menu"
self.widget = urwid.AttrMap(urwid.Columns([urwid.Text(base), self.status], dividechars=2), "shortcutbar")
def set_status(self, msg):
self.status.set_text(msg)
class PageEditorDisplay():
def __init__(self, app):
self.app = app
self.current_path = None
self.editable = False
self.is_exec = False
self.dirty = False
self.preview_alarm = None
self._loading = False
self._find = None
self._last_file = None
self.expanded = set(p for p in (app.pagespath, app.filespath) if p)
self.filter_mu = False
self.pane_visible = {"files": True, "editor": True, "preview": True}
self.load_state()
self.register_styles()
self.preview_browser = Browser(self.app, "nomadnetwork", "node", delegate=None)
self.preview_browser.handle_link = self._preview_handle_link
self.preview_browser.marked_link = lambda *a, **k: None
self.preview_browser._content_cols = self._preview_cols
# don't fetch partials in the preview
self.preview_browser.detect_partials = lambda: None
self.editor = GutterEdit(caption="", edit_text="", multiline=True)
urwid.connect_signal(self.editor, "postchange", self.on_edit_change)
self.filter_checkbox = urwid.CheckBox("Show only .mu", state=self.filter_mu, on_state_change=self.on_filter_change)
self.tree_walker = urwid.SimpleFocusListWalker(self.build_entries())
self.tree_listbox = IndicativeListBox(self.tree_walker, initialization_is_selection_change=False, highlight_offFocus="list_off_focus")
self.files_pile = urwid.Pile([
(urwid.PACK, urwid.AttrMap(self.filter_checkbox, None, focus_map="list_focus")),
(urwid.WEIGHT, 1, self.tree_listbox),
])
try: self.files_pile.focus_position = 1
except Exception: pass
self.pane_widgets = {
"files": urwid.LineBox(self.files_pile, title="Files"),
"editor": urwid.LineBox(urwid.ListBox(urwid.SimpleListWalker([self.editor])), title="Editor"),
"preview": urwid.LineBox(urwid.SolidFill(" "), title="Preview"),
}
self.columns = EditorColumns([], dividechars=0)
self.columns.editor_display = self
self._visible = []
self.rebuild_columns()
self.shortcuts_display = PageEditorShortcuts(self.app)
self.body = urwid.WidgetPlaceholder(self.columns)
self.widget = self.body
def register_styles(self):
screen = self.app.ui.screen
for entry in EDITOR_STYLES:
try: screen.register_palette_entry(*entry)
except Exception: pass
def _preview_handle_link(self, target, link_data=None):
# in the preview follow in page anchors but ignore external navigation
# TODO
if isinstance(target, str) and target.startswith("#"):
name = target[1:]
self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self.preview_browser._jump_to_anchor(name))
def _preview_cols(self):
try:
cols = self.app.ui.loop.screen.get_cols_rows()[0]
visible = self.visible_panes()
total = sum(PANE_WEIGHTS[n] for n in visible)
frac = PANE_WEIGHTS["preview"] / total if "preview" in visible else PANE_WEIGHTS["preview"]
return max(20, int(cols * frac) - 3)
except Exception:
return 80
def shortcuts(self):
return self.shortcuts_display
def start(self):
self.refresh_tree()
if self._last_file and os.path.isfile(self._last_file) and self.current_path is None:
self.load_file(self._last_file)
self._last_file = None
def files_focused(self):
try: return self.visible_panes()[self.columns.focus_position] == "files"
except Exception: return False
def _state_file(self):
d = getattr(self.app, "configdir", None)
return os.path.join(d, "editor_state.json") if d else None
def load_state(self):
p = self._state_file()
if not p or not os.path.isfile(p):
return
try:
with open(p) as f:
st = json.load(f)
self.filter_mu = bool(st.get("filter_mu", self.filter_mu))
if isinstance(st.get("expanded"), list):
self.expanded = set(st["expanded"])
pv = st.get("pane_visible")
if isinstance(pv, dict):
for k in self.pane_visible:
if k in pv: self.pane_visible[k] = bool(pv[k])
self._last_file = st.get("last_file")
except Exception:
pass
def save_state(self):
p = self._state_file()
if not p:
return
try:
self._atomic_write(p, json.dumps({"filter_mu": self.filter_mu, "expanded": sorted(self.expanded),
"pane_visible": self.pane_visible, "last_file": self.current_path}))
except Exception:
pass
def _selected_tree(self):
try:
sel = self.tree_listbox.get_selected_item()
return sel.original_widget if sel else None
except Exception:
return None
def _roots(self):
return [r for r in (self.app.pagespath, self.app.filespath) if r]
def _is_root(self, path):
try:
rp = os.path.realpath(path)
except Exception:
return False
return any(rp == os.path.realpath(r) for r in self._roots())
def _confine(self, path, base=None):
# Never save outside of the Nomad storage directory
path = (path or "").strip()
if not path:
return None
path = os.path.expanduser(path)
if not os.path.isabs(path):
base = base or self.app.pagespath or self.app.filespath
if not base:
return None
path = os.path.join(base, path)
norm = os.path.normpath(path)
for root in self._roots():
rp = os.path.normpath(root)
if norm == rp or norm.startswith(rp + os.sep):
return norm
return None
def _atomic_write(self, path, text):
d = os.path.dirname(path) or "."
tmp = os.path.join(d, "." + os.path.basename(path) + ".tmp")
try:
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
try: os.fsync(f.fileno())
except Exception: pass
os.replace(tmp, path)
except Exception:
try: os.remove(tmp)
except Exception: pass
raise
def _confirm_overwrite(self, dest, proceed):
self.show_dialog(ConfirmDialog("Overwrite",
"%s already exists.\nOverwrite it?" % os.path.basename(dest),
[("Overwrite", proceed), ("Cancel", self.close_dialog)],
on_cancel=self.close_dialog))
def visible_panes(self):
return [n for n in PANE_ORDER if self.pane_visible[n]] or ["editor"]
def rebuild_columns(self):
focused = None
try: focused = self._visible[self.columns.focus_position]
except Exception: pass
visible = self.visible_panes()
self.columns.contents = [
(self.pane_widgets[n], self.columns.options(urwid.WEIGHT, PANE_WEIGHTS[n], box_widget=True))
for n in visible
]
self._visible = visible
# the rpeview starts non-selectable
target = focused if focused in visible else ("files" if "files" in visible else visible[0])
try: self.columns.focus_position = visible.index(target)
except Exception: pass
def toggle_pane(self, name):
visible = self.visible_panes()
if self.pane_visible[name] and len(visible) <= 1:
return
self.pane_visible[name] = not self.pane_visible[name]
self.rebuild_columns()
self.save_state()
def refresh_tree(self):
focus_path = None
try:
sel = self.tree_listbox.get_selected_item()
if sel is not None:
focus_path = getattr(sel.original_widget, "path", None)
except Exception:
pass
entries = self.build_entries()
# indicativelistbox wraps items in attrmap for highlighting
self.tree_walker[:] = [urwid.AttrMap(e, None) for e in entries]
if focus_path is not None:
for i, e in enumerate(entries):
if getattr(e, "path", None) == focus_path:
try: self.tree_listbox.select_item(i)
except Exception: pass
break
def build_entries(self):
entries = []
for label, root in (("pages", self.app.pagespath), ("files", self.app.filespath)):
if not root or not os.path.isdir(root):
continue
entries.extend(self.walk_dir(label, root, 0))
if not entries:
entries = [urwid.Text("no files")]
return entries
def walk_dir(self, name, path, depth):
out = []
expanded = path in self.expanded
folder = FolderEntry(name, path, depth, expanded, self.app.ui.glyphs)
urwid.connect_signal(folder, "click", self.on_folder_click)
out.append(folder)
if not expanded:
return out
try:
names = sorted(os.listdir(path), key=str.lower)
except Exception:
return out
dirs = [n for n in names if os.path.isdir(os.path.join(path, n)) and not n.startswith(".") and n != "__pycache__"]
files = [n for n in names if not os.path.isdir(os.path.join(path, n)) and not n.startswith(".")]
if self.filter_mu:
files = [n for n in files if n.endswith(".mu")]
dirs = [d for d in dirs if self._dir_has_mu(os.path.join(path, d))]
for d in dirs:
out.extend(self.walk_dir(d, os.path.join(path, d), depth + 1))
for fn in files:
full = os.path.join(path, fn)
entry = FileEntry(fn, full, depth + 1, executable=is_executable(full))
urwid.connect_signal(entry, "click", self.on_file_click)
out.append(entry)
return out
def _dir_has_mu(self, path):
for root, dirs, files in os.walk(path):
dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"]
if any(f.endswith(".mu") for f in files):
return True
return False
def on_filter_change(self, checkbox, state):
self.filter_mu = state
self.refresh_tree()
self.save_state()
def on_folder_click(self, folder):
if folder.path in self.expanded:
self.expanded.discard(folder.path)
else:
self.expanded.add(folder.path)
self.refresh_tree()
self.save_state()
def on_file_click(self, entry):
self.open_path(entry.path)
def open_path(self, path):
if self.dirty:
self.confirm_discard(lambda: self._do_open(path))
else:
self._do_open(path)
def _do_open(self, path):
self.load_file(path)
if self.pane_visible["editor"]:
try: self.columns.focus_position = self.visible_panes().index("editor")
except Exception: pass
def confirm_discard(self, proceed):
def save_then():
self.close_dialog()
if self.current_path and self.editable:
self.save_file(self.current_path)
proceed()
name = os.path.basename(self.current_path) if self.current_path else "this file"
self.show_dialog(ConfirmDialog("Unsaved changes", "Discard unsaved changes to %s?" % name,
[("Save", save_then), ("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)],
on_cancel=self.close_dialog))
def load_file(self, path):
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.editable = True
except UnicodeDecodeError:
content = ""
self.editable = False
except Exception as e:
self.set_status("open failed: %s" % e); return
self.current_path = path
self.is_exec = is_executable(path)
self.dirty = False
self._loading = True
self.editor.set_edit_text(content)
self.editor.set_edit_pos(0)
self._loading = False
name = os.path.basename(path)
self.update_title()
self.save_state()
if not self.editable:
self.set_status("binary file (not editable)")
elif self.is_exec:
self.set_preview_note(name + " is an executable page. the node runs it and serves its output.")
self.set_status("executable page")
self.show_info_dialog("Executable page", name + " is marked executable (chmod +x). NomadNet runs it and serves its output as the page, so the preview shows the source rather than the rendered output.")
else:
self.render_preview()
self.set_status("opened " + name)
def update_title(self, modified=False):
if self.current_path is None:
self.pane_widgets["editor"].set_title("Editor"); return
suffix = " (binary)" if not self.editable else (" (executable)" if self.is_exec else "")
if modified:
suffix += " *"
self.pane_widgets["editor"].set_title("Editor: " + os.path.basename(self.current_path) + suffix)
def set_preview_note(self, msg):
self.pane_widgets["preview"].original_widget = urwid.Filler(urwid.Text(msg), urwid.TOP)
def show_info_dialog(self, title, message):
self.show_dialog(InfoDialog(title, message, self.close_dialog))
def save_or_saveas(self):
if self.current_path is None:
self.save_as_dialog(); return
self.save_file(self.current_path)
def save_file(self, path):
if not self.editable and self.current_path is not None:
self.set_status("cannot save binary file"); return
try:
self._atomic_write(path, self.editor.edit_text)
self.current_path = path
self.editable = True
self.dirty = False
self.set_modified(False)
self.set_status("saved " + os.path.basename(path))
self.refresh_tree()
self.save_state()
except Exception as e:
self.set_status("save failed: %s" % e)
def new_file_dialog(self):
default = (self.app.pagespath or "") + os.sep
self.show_dialog(PathDialog("New (end with / for a folder)", default, self._do_new, self.close_dialog))
def _do_new(self, path):
self.close_dialog()
raw = (path or "").strip()
if not raw:
return
is_folder = raw.endswith("/") or raw.endswith(os.sep)
target = self._confine(raw)
if target is None:
self.set_status("path must be inside pages/ or files/"); return
try:
if is_folder:
os.makedirs(target, exist_ok=True)
self.expanded.add(target)
self.refresh_tree(); self.save_state()
self.set_status("created folder " + os.path.basename(target))
else:
os.makedirs(os.path.dirname(target), exist_ok=True)
if not os.path.exists(target):
self._atomic_write(target, "")
self.refresh_tree()
self.open_path(target)
except Exception as e:
self.set_status("new failed: %s" % e)
def rename_dialog(self):
w = self._selected_tree()
if not w or not getattr(w, "path", None):
self.set_status("select a file/folder to rename"); return
if self._is_root(w.path):
self.set_status("cannot rename the pages/files root"); return
self.show_dialog(PathDialog("Rename", w.path, lambda p, old=w.path: self._do_rename(old, p), self.close_dialog))
def _do_rename(self, old, new):
self.close_dialog()
target = self._confine(new, base=os.path.dirname(old))
if target is None:
self.set_status("path must be inside pages/ or files/"); return
if target == os.path.normpath(old):
return
def proceed():
self.close_dialog()
try:
os.makedirs(os.path.dirname(target), exist_ok=True)
os.rename(old, target)
if self.current_path == old:
self.current_path = target; self.update_title()
if old in self.expanded:
self.expanded.discard(old); self.expanded.add(target)
self.refresh_tree(); self.save_state()
self.set_status("renamed to " + os.path.basename(target))
except Exception as e:
self.set_status("rename failed: %s" % e)
if os.path.exists(target) and os.path.realpath(target) != os.path.realpath(old):
self._confirm_overwrite(target, proceed)
else:
proceed()
def delete_dialog(self):
w = self._selected_tree()
if not w or not getattr(w, "path", None):
self.set_status("select a file/folder to delete"); return
path = w.path
if self._is_root(path):
self.set_status("cannot delete the pages/files root"); return
self.show_dialog(ConfirmDialog("Delete", "Delete %s?" % os.path.basename(path),
[("Delete", lambda: self._do_delete(path)), ("Cancel", self.close_dialog)], on_cancel=self.close_dialog))
def _do_delete(self, path):
self.close_dialog()
try:
if os.path.isdir(path):
shutil.rmtree(path); self.expanded.discard(path)
else:
os.remove(path)
if self.current_path == path:
self.current_path = None; self.editable = False; self.is_exec = False; self.dirty = False
self._loading = True; self.editor.set_edit_text(""); self._loading = False
self.update_title(); self.set_preview_note("")
self.refresh_tree(); self.save_state()
self.set_status("deleted " + os.path.basename(path))
except Exception as e:
self.set_status("delete failed: %s" % e)
def permissions_dialog(self):
w = self._selected_tree()
if not w or not getattr(w, "path", None):
self.set_status("select a file/folder for permissions"); return
path = w.path
is_dir = os.path.isdir(path)
content = ""
if not is_dir and os.path.isfile(path + ".allowed"):
try: content = open(path + ".allowed", encoding="utf-8").read()
except Exception: content = ""
note = ("Allowed identity hashes per line. Empty = public.\n"
+ ("Applies to every page in this folder." if is_dir else "Applys to this page."))
title = "Permissions: " + os.path.basename(path) + ("/" if is_dir else "")
self.show_dialog(PermissionsDialog(title, note, content,
lambda txt: self._save_permissions(path, is_dir, txt), self.close_dialog))
def _save_permissions(self, path, is_dir, text):
self.close_dialog()
body = "\n".join(ln.strip() for ln in text.splitlines() if ln.strip())
try:
if is_dir:
count = 0
for root, dirs, files in os.walk(path):
dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"]
for fn in files:
if fn.endswith(".allowed"):
continue
self._write_allowed(os.path.join(root, fn), body)
count += 1
self.set_status("permissions applied to %d files" % count)
else:
self._write_allowed(path, body)
self.set_status("permissions saved" if body else "permissions cleared (public)")
self.refresh_tree()
except Exception as e:
self.set_status("permissions failed: %s" % e)
def _write_allowed(self, path, body):
ap = path + ".allowed"
if body:
self._atomic_write(ap, body + "\n")
elif os.path.exists(ap):
os.remove(ap)
def snippet_dialog(self):
self.show_dialog(SnippetDialog(self._insert_snippet, self.close_dialog))
def _insert_snippet(self, text):
self.close_dialog()
if self.pane_visible["editor"]:
try: self.columns.focus_position = self.visible_panes().index("editor")
except Exception: pass
pos = self.editor.edit_pos
t = self.editor.edit_text
self.editor.set_edit_text(t[:pos] + text + t[pos:])
self.editor.set_edit_pos(pos + len(text))
def find_dialog(self):
if self._find is None:
self._find = FindDialog(self, self.close_dialog)
self.show_dialog(self._find)
def find_next(self, term):
if not term:
return
t = self.editor.edit_text
idx = t.find(term, self.editor.edit_pos)
if idx < 0:
idx = t.find(term, 0)
if idx < 0:
self.set_status("'%s' not found" % term); return
self.editor.set_edit_pos(idx + len(term))
self.set_status("found at %d" % idx)
def replace_one(self, term, repl):
if not term:
return
t = self.editor.edit_text
end = self.editor.edit_pos
start = end - len(term)
if start >= 0 and t[start:end] == term:
self.editor.set_edit_text(t[:start] + repl + t[end:])
self.editor.set_edit_pos(start + len(repl))
self.set_status("replaced")
else:
self.find_next(term)
def replace_all(self, term, repl):
if not term:
return
cnt = self.editor.edit_text.count(term)
if cnt:
self.editor.set_edit_text(self.editor.edit_text.replace(term, repl))
self.set_status("replaced %d" % cnt)
else:
self.set_status("'%s' not found" % term)
def save_as_dialog(self):
default = self.current_path or ((self.app.pagespath or "") + os.sep)
self.show_dialog(PathDialog("Save as", default, self._do_saveas, self.close_dialog))
def _do_saveas(self, path):
self.close_dialog()
target = self._confine(path)
if target is None:
self.set_status("path must be inside pages/ or files/"); return
def proceed():
self.close_dialog()
try:
os.makedirs(os.path.dirname(target), exist_ok=True)
except Exception:
pass
self.editable = True
self.save_file(target)
already = self.current_path is None or os.path.realpath(target) != os.path.realpath(self.current_path)
if os.path.exists(target) and already:
self._confirm_overwrite(target, proceed)
else:
proceed()
def show_dialog(self, dialog):
self.dialog = dialog
overlay = urwid.Overlay(
dialog, self.columns,
align=urwid.CENTER, width=(urwid.RELATIVE, 60),
valign=urwid.MIDDLE, height=(urwid.RELATIVE, 40),
min_width=30, min_height=8,
)
self.body.original_widget = overlay
def close_dialog(self):
self.dialog = None
self.body.original_widget = self.columns
def on_edit_change(self, widget, old_text):
if self._loading or not self.editable:
return
self.dirty = True
self.set_modified(True)
if self.is_exec:
return
if self.preview_alarm is not None:
try: self.app.ui.loop.remove_alarm(self.preview_alarm)
except Exception: pass
self.preview_alarm = self.app.ui.loop.set_alarm_in(PREVIEW_DELAY, self._render_preview_alarm)
def _render_preview_alarm(self, loop, user_data):
self.preview_alarm = None
self.render_preview()
def render_preview(self):
if len(self.editor.edit_text) > MAX_PREVIEW_CHARS:
self.set_preview_note("File too large to preview (%d KB). Editing still works." % (len(self.editor.edit_text) // 1000))
return
pos = self._preview_scrollpos()
try:
body = self.preview_browser.render_markup_buffer(self.editor.edit_text)
except Exception as e:
body = urwid.Filler(urwid.Text("preview error: %s" % e), urwid.TOP)
self.pane_widgets["preview"].original_widget = body
self._set_preview_scrollpos(pos)
def _preview_scrollable(self):
try:
return self.preview_browser.browser_body.original_widget.original_widget
except Exception:
return None
def _preview_scrollpos(self):
s = self._preview_scrollable()
try: return s.get_scrollpos()
except Exception: return None
def _set_preview_scrollpos(self, pos):
if pos is None: return
s = self._preview_scrollable()
try: s.set_scrollpos(pos)
except Exception: pass
def set_modified(self, modified):
self.update_title(modified)
def set_status(self, msg):
self.shortcuts_display.set_status(msg)