Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions tools/developer_tools/bely-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,11 +404,18 @@ you highlight each entry) — and, for a reply, which entry it's a reply to.

Keys:

The footer at the bottom of the screen only ever shows the keys that apply to the level you're
on — `i` disappears once you drill into entries, and `s` / `y` / `e` / `f` / `t` only appear there.
Press `?` to open a categorized keyboard-shortcut reference. With its search box empty it
shows the shortcuts relevant to the current browse level; type a key name such as `ctrl+s`
or an action such as `reply` to search the complete TUI shortcut catalog. Search matches
shortcut notation and action text. Press `Esc` or `?` to close it.

The footer at the bottom of the screen only ever shows the keys that apply to the level and
data you're viewing — `i` disappears once you drill into entries, and `s` / `y` / `e` / `f`
only appear there. `t` appears at the entries level only when the document has replies.

| Key | Action |
|-----|--------|
| `?` | Open the searchable, categorized keyboard-shortcut reference. |
| `Up` / `Down`, `PgUp` / `PgDn` | Move the highlight; the preview/info panel follows. |
| `/` | Reveal and focus the filter box; incrementally filters the current table (case-insensitive substring, matched against the entry text — not the reply tree's glyphs or reply count). |
| `Enter` | In the filter box, return focus to the table. Elsewhere, drill into the highlighted row, or select the entry at the entries level. |
Expand All @@ -417,8 +424,8 @@ on — `i` disappears once you drill into entries, and `s` / `y` / `e` / `f` / `
| `s` | Entries level only: save the highlighted entry's markdown to a file in the current directory. |
| `y` | Entries level only: copy a `bely-cli entry get` reference for the highlighted entry to the clipboard. |
| `e` | Entries level only: open the highlighted entry in `$EDITOR`; if you change it, offers to save the result back to the server (a mutation, so this is where the app authenticates if it hasn't already). |
| `p` | Entries level only: reply to the highlighted entry's top-level thread. Attachments are supported. |
| `t` | Entries level only: collapse/expand the reply thread under the highlighted entry (or its parent, if the highlight is on a reply). Replies start expanded. |
| `p` | Entries level only: add a reply to the highlighted entry's top-level thread. Attachments are supported. |
| `t` | Entries level only, when the document has replies: toggle (collapse/expand) the reply thread under the highlighted entry, or its parent if the highlight is on a reply. Replies start expanded. |
| `Shift+A` | Entries level only: open the highlighted entry's attachment browser. |
| `x` | Document/entry levels only: delete the highlighted document, entry, or reply after destructive confirmation. Documents containing entries require a second confirmation by typing the document name exactly. |
| `i` | Logbook/document levels only: toggle the side info panel. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,11 @@ def _load_local_image(self, path, token):
async def _show_local_image(self, token, widget_cls, image):
if token != self._preview_token:
return
await self.query_one("#attachment-file-media", Vertical).mount(
widget_cls(image, id="attachment-file-image"))
media = self.query_one("#attachment-file-media", Vertical)
await media.remove_children()
if token != self._preview_token:
return
await media.mount(widget_cls(image, id="attachment-file-image"))

def _preview_local_failed(self, token, message):
if token == self._preview_token:
Expand Down Expand Up @@ -429,8 +432,11 @@ def _load_image(self, attachment, token):
async def _show_image(self, token, widget_cls, image):
if token != self._preview_token:
return
await self.query_one("#attachment-media", Vertical).mount(
widget_cls(image, id="attachment-image"))
media = self.query_one("#attachment-media", Vertical)
await media.remove_children()
if token != self._preview_token:
return
await media.mount(widget_cls(image, id="attachment-image"))

def _preview_failed(self, token, message):
if token == self._preview_token:
Expand Down
48 changes: 42 additions & 6 deletions tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,16 @@ class BrowseScreen(Screen):
Binding("escape", "back", "Back"),
Binding("backspace", "back", "Back", show=False),
Binding("q", "quit_app", "Quit"),
Binding("question_mark", "show_shortcuts", "Shortcuts"),
Binding("slash", "focus_filter", "Filter"),
Binding("f", "toggle_full", "Full"),
Binding("t", "toggle_replies", "Replies"),
Binding("t", "toggle_replies", "Toggle replies"),
Binding("s", "save_entry", "Save"),
Binding("y", "copy_reference", "Copy ref"),
Binding("e", "open_editor", "Edit in editor"),
Binding("n", "new_entry", "New entry"),
Binding("u", "update_entry", "Edit in TUI"),
Binding("p", "reply", "Reply"),
Binding("p", "reply", "Add reply"),
# Printable shifted letters arrive from terminals as the uppercase character.
Binding("A", "attachments", "Attachments"),
Binding("x", "delete", "Delete"),
Expand Down Expand Up @@ -149,6 +150,7 @@ def __init__(self, session, limit, *, select_mode=True, source="types", root=Fal
self._restoring_entry_preview = False
self._restoring_entry_id = None
self._pending_doc_row = None
self._entry_refresh_pending = False

def compose(self) -> ComposeResult:
yield Header()
Expand All @@ -173,6 +175,11 @@ def on_mount(self):

def on_screen_resume(self):
self._update_auth_status()
if self._entry_refresh_pending and self.level == self.LEVEL_ENTRIES:
self._entry_refresh_pending = False
entry = self._current_entry()
if entry is not None:
self._reload_entry(entry.log_id)

# -- nav widget --

Expand Down Expand Up @@ -669,6 +676,16 @@ def _exit_top(self):
def action_quit_app(self):
self.app.exit(None)

def action_show_shortcuts(self):
from .shortcuts import ShortcutScreen

context = {
self.LEVEL_TYPES: "types",
self.LEVEL_DOCS: "docs",
self.LEVEL_ENTRIES: "entries",
}[self.level]
self.app.push_screen(ShortcutScreen(context, has_replies=self._has_replies()))

def action_focus_filter(self):
filt = self.query_one("#filter", Input)
filt.display = True
Expand All @@ -688,6 +705,8 @@ def check_action(self, action, parameters):
return None
if action == "new_entry" and self._current_doc() is None:
return None
if action == "toggle_replies" and not self._has_replies():
return None
if action == "reply" and self._current_node() is None:
return None
if action == "delete" and self._current_item() is None:
Expand All @@ -698,6 +717,9 @@ def check_action(self, action, parameters):
return None
return True

def _has_replies(self):
return any(getattr(entry, "log_replies", None) for entry in self.entry_tree)

def action_refresh_level(self):
# Do not start another entry refresh while its preview state is being
# restored; repeated refreshes would otherwise race over one snapshot.
Expand Down Expand Up @@ -943,9 +965,7 @@ async def _edit_entry_externally(self, entry):
)
return

self.data.invalidate("entries", doc_id=self.sel_doc.id)
self.show_level(
self.LEVEL_ENTRIES, preserve_filter=True, preserve_entry_position=True)
self._reload_entry(entry.log_id)
self.notify("Entry saved.")

def action_attachments(self):
Expand All @@ -959,8 +979,21 @@ def action_attachments(self):
self.session, self.sel_doc, entry, on_changed=self._refresh_attachment_entry))

def _refresh_attachment_entry(self):
"""Reload the entry after its attachment references change."""
"""Reload the entry once the attachment modal closes."""
self.data.invalidate("entries", doc_id=self.sel_doc.id)
self._entry_refresh_pending = True

def _reload_entry(self, log_id):
"""Fetch and rerender one mutated entry while preserving its position."""
self.data.invalidate("entries", doc_id=self.sel_doc.id)
self._pending_entry_restore = (
log_id,
None,
self._nav().cursor_row or 0,
self.query_one("#preview", VerticalScroll).scroll_y,
)
self._restoring_entry_preview = True
self._restoring_entry_id = log_id
self.show_level(
self.LEVEL_ENTRIES, preserve_filter=True, preserve_entry_position=True)

Expand Down Expand Up @@ -1003,6 +1036,9 @@ async def _run_compose(self, doc, entry, reply_to=None):
return
self.sel_doc = doc
self.data.invalidate("entries", doc_id=doc.id)
if entry is not None:
self._reload_entry(saved.log_id)
return
if reply_to is not None:
self._collapsed.discard(reply_to.log_id)
saved_id = getattr(saved, "log_id", None) or reply_to.log_id
Expand Down
190 changes: 190 additions & 0 deletions tools/developer_tools/bely-cli/src/bely_cli/tui/screens/shortcuts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""Searchable keyboard-shortcut reference for the TUI."""

from typing import NamedTuple

from textual import events
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical, VerticalScroll
from textual.widgets import Input, Label, Static

from .dialog import DialogScreen


class Shortcut(NamedTuple):
key: str
action: str
category: str
contexts: tuple[str, ...]


CATEGORY_ORDER = (
"Navigation",
"Browsing and display",
"Documents and entries",
"Attachments",
"Dialogs and editing",
"Application",
)

ALL_SHORTCUTS = (
Shortcut("Up / Down", "Move the highlight", "Navigation", ("types", "docs", "entries", "attachments")),
Shortcut("PgUp / PgDn", "Move by one page", "Navigation", ("types", "docs", "entries", "attachments")),
Shortcut("Enter", "Open or select the highlighted item", "Navigation", ("types", "docs", "entries")),
Shortcut("Esc / Backspace", "Go back one level", "Navigation", ("types", "docs", "entries")),
Shortcut("/", "Filter the current table", "Browsing and display", ("types", "docs", "entries")),
Shortcut("i", "Toggle the details panel", "Browsing and display", ("types", "docs")),
Shortcut("f", "Toggle the full-width entry preview", "Browsing and display", ("entries",)),
Shortcut("t", "Toggle replies", "Browsing and display", ("entries_with_replies",)),
Shortcut("r", "Refresh the current level", "Browsing and display", ("types", "docs", "entries")),
Shortcut("d", "Create a document", "Documents and entries", ("types", "docs")),
Shortcut("n", "Add a top-level entry", "Documents and entries", ("docs", "entries")),
Shortcut("p", "Add a reply", "Documents and entries", ("entries",)),
Shortcut("u", "Edit the highlighted entry in the TUI", "Documents and entries", ("entries",)),
Shortcut("e", "Edit the highlighted entry in $EDITOR", "Documents and entries", ("entries",)),
Shortcut("s", "Save the highlighted entry to a file", "Documents and entries", ("entries",)),
Shortcut("y", "Copy a CLI reference", "Documents and entries", ("entries",)),
Shortcut("x", "Delete the highlighted document or entry", "Documents and entries", ("docs", "entries")),
Shortcut("Shift+A", "Open the attachment browser", "Attachments", ("entries",)),
Shortcut("u", "Upload an attachment", "Attachments", ("attachments",)),
Shortcut("y", "Copy an attachment reference", "Attachments", ("attachments",)),
Shortcut("x", "Delete an attachment", "Attachments", ("attachments",)),
Shortcut("p", "Toggle the local-file preview", "Attachments", ("attachment_picker",)),
Shortcut("/", "Filter filenames", "Attachments", ("attachment_picker",)),
Shortcut("Tab / Right", "Complete a filesystem path", "Attachments", ("attachment_picker",)),
Shortcut("Ctrl+U", "Clear the filesystem path", "Attachments", ("attachment_picker",)),
Shortcut("Ctrl+Shift+A", "Select the filesystem path", "Attachments", ("attachment_picker",)),
Shortcut("Ctrl+S", "Submit or save the current dialog", "Dialogs and editing", ("dialogs",)),
Shortcut("Esc", "Cancel or close the current dialog", "Dialogs and editing", ("dialogs", "attachments")),
Shortcut("Tab / Shift+Tab", "Move between fields and buttons", "Dialogs and editing", ("dialogs",)),
Shortcut("Arrow keys", "Move between dialog buttons", "Dialogs and editing", ("dialogs",)),
Shortcut("Space", "Toggle an item in a multi-select picker", "Dialogs and editing", ("dialogs",)),
Shortcut("?", "Open or close keyboard shortcuts", "Application", ("types", "docs", "entries")),
Shortcut("Ctrl+P", "Open the command palette", "Application", ("types", "docs", "entries")),
Shortcut("q", "Quit", "Application", ("types", "docs", "entries")),
)


def shortcuts_for(context, *, has_replies=False, query=""):
"""Return relevant shortcuts, or key/action matches from the full catalog."""
if query:
needle = query.casefold()
return [
shortcut for shortcut in ALL_SHORTCUTS
if needle in shortcut.key.casefold() or needle in shortcut.action.casefold()
]
contexts = {context}
if context == "entries" and has_replies:
contexts.add("entries_with_replies")
return [shortcut for shortcut in ALL_SHORTCUTS if contexts.intersection(shortcut.contexts)]


def grouped_shortcuts(shortcuts):
"""Return non-empty shortcut categories in display order."""
return [
(category, [shortcut for shortcut in shortcuts if shortcut.category == category])
for category in CATEGORY_ORDER
if any(shortcut.category == category for shortcut in shortcuts)
]


def format_shortcuts(shortcuts):
"""Format shortcut rows under stable logical category headings."""
lines = []
for category, rows in grouped_shortcuts(shortcuts):
if lines:
lines.append("")
lines.append(category)
lines.append("─" * len(category))
width = max(len(shortcut.key) for shortcut in rows)
lines.extend(f"{shortcut.key:<{width}} {shortcut.action}" for shortcut in rows)
return "\n".join(lines) if lines else "No shortcuts match that key."


class ShortcutFilterInput(Input):
"""Search field where `?` closes help instead of entering the character."""

def on_key(self, event: events.Key):
if event.key in {"question_mark", "?"} or event.character == "?":
event.prevent_default()
event.stop()
self.screen.dismiss(None)


class ShortcutScreen(DialogScreen):
"""Modal shortcut list, relevant by default and global while searching."""

DEFAULT_CSS = """
#shortcut-dialog { width: 76; height: 80%; }
#shortcut-filter { margin: 1 0; }
#shortcut-scroll { height: 1fr; }
#shortcut-list { height: auto; }
.shortcut-section-header {
width: 100%;
height: 1;
margin: 1 0 0 0;
padding: 0 1;
background: $primary;
color: $text;
text-style: bold;
}
.shortcut-rows {
height: auto;
padding: 0 1;
}
.shortcut-empty {
height: auto;
padding: 1;
color: $text-muted;
}
"""

BINDINGS = [
Binding("question_mark", "cancel", "Close", show=False, priority=True),
]

def __init__(self, context, *, has_replies=False):
super().__init__()
self.context = context
self.has_replies = has_replies

def compose(self) -> ComposeResult:
with Vertical(id="shortcut-dialog", classes="dialog"):
yield Static("Keyboard shortcuts · Esc/? close", id="shortcut-title")
yield ShortcutFilterInput(
placeholder="Search keys and actions", id="shortcut-filter")
with VerticalScroll(id="shortcut-scroll"):
yield Vertical(id="shortcut-list")

async def on_mount(self):
await self._show("")
self.query_one("#shortcut-filter", Input).focus()

async def on_input_changed(self, event):
if event.input.id == "shortcut-filter":
await self._show(event.value)

async def _show(self, query):
shortcuts = shortcuts_for(
self.context, has_replies=self.has_replies, query=query.strip())
listing = self.query_one("#shortcut-list", Vertical)
await listing.remove_children()
groups = grouped_shortcuts(shortcuts)
if not groups:
await listing.mount(
Static("No shortcuts match that key.", classes="shortcut-empty"))
return
widgets = []
for category, rows in groups:
width = max(len(shortcut.key) for shortcut in rows)
widgets.extend((
Label(category, classes="shortcut-section-header"),
Static(
"\n".join(
f"[b]{shortcut.key:<{width}}[/b] {shortcut.action}"
for shortcut in rows
),
classes="shortcut-rows",
),
))
await listing.mount_all(widgets)
Loading
Loading