Initial work on a browse action.

This commit is contained in:
Dan Helfman
2026-05-14 22:24:06 -07:00
parent aaf5812a3f
commit e339ce2fd1
8 changed files with 405 additions and 6 deletions
+2
View File
@@ -14,6 +14,8 @@ pyyaml
referencing
requests
requests-oauthlib
rich
rpds-py
ruamel-yaml
textual
urllib3
+2
View File
@@ -16,6 +16,8 @@ pyyaml==6.0.3 # via apprise, -r binary_requirements.in
referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r binary_requirements.in
requests==2.34.0 # via apprise, borgmatic, requests-oauthlib, -r binary_requirements.in
requests-oauthlib==2.0.0 # via apprise, -r binary_requirements.in
rich==15.0.0
rpds-py==0.30.0 # via jsonschema, referencing, -r binary_requirements.in
ruamel-yaml==0.19.1 # via borgmatic, -r binary_requirements.in
textual==8.2.5
urllib3==2.7.0 # via requests, -r binary_requirements.in
+369
View File
@@ -0,0 +1,369 @@
import argparse
import contextlib
import enum
import functools
import json
import os
import logging
import borgmatic.actions.pattern
import borgmatic.borg.diff
import borgmatic.borg.repo_list
import borgmatic.borg.version
import borgmatic.logger
import rich.text
import textual._context
import textual.app
import textual.binding
import textual.reactive
import textual.widgets
logger = logging.getLogger(__name__)
class Node_type(enum.Enum):
CONFIGURATION = 0
REPOSITORY = 1
ARCHIVE = 2
DIRECTORY = 3
FILE = 4
DUMP = 5
BOOTSTRAP = 6
LOADING = 7
PATH_TYPE_ICONS = {
Node_type.CONFIGURATION: '⚙️ ',
Node_type.REPOSITORY: '🗃️ ',
Node_type.ARCHIVE: '🗂️ ',
Node_type.DIRECTORY: '📁',
Node_type.FILE: '📄',
Node_type.DUMP: '🗄️',
Node_type.BOOTSTRAP: '🥾',
Node_type.LOADING: '',
}
PATH_TYPE_EXPANDED_ICONS = {Node_type.DIRECTORY: '📂'}
@textual.work(thread=True)
async def get_repository_archives(browse_tree, repository_node, config, repository, timer):
with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])):
logger.answer('Listing repository')
repo_list_arguments = argparse.Namespace(
repository=repository['path'],
short=None,
format=None,
json=True,
prefix=None,
match_archives=None,
sort_by=None,
first=None,
last=None,
)
global_arguments = argparse.Namespace()
local_path = config.get('local_path', 'borg')
remote_path = config.get('remote_path')
local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path)
archives_data = json.loads(
borgmatic.borg.repo_list.list_repository(
repository['path'],
config,
local_borg_version,
repo_list_arguments,
global_arguments,
local_path,
remote_path,
)
)
browse_tree.app.call_from_thread(add_repository_archives, repository_node, archives_data, timer)
def add_repository_archives(repository_node, archives_data, timer):
timer.stop()
for child in repository_node.children:
child.remove()
# Reverse the archives, so the common case of accessing the latest archive is easy because it's
# at the top.
for archive in reversed(archives_data['archives']):
repository_node.add(
archive['archive'],
data={
'type': Node_type.ARCHIVE,
'config': repository_node.data['config'],
'repository': repository_node.data['repository'],
'archive': archive,
},
)
@textual.work(thread=True)
def get_archive_files(browse_tree, archive_node, config, repository, archive, timer):
with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])):
logger.answer(f"Listing archive {archive['archive']}")
global_arguments = argparse.Namespace()
local_path = config.get('local_path', 'borg')
remote_path = config.get('remote_path')
local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path)
file_data = borgmatic.borg.list.capture_archive_listing(
repository['path'],
archive['archive'],
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
browse_tree.app.call_from_thread(add_archive_files, archive_node, file_data, timer)
def add_archive_files(archive_node, file_data, timer):
config = archive_node.data['config']
repository = archive_node.data['repository']
archive = archive_node.data['archive']
timer.stop()
for child in archive_node.children:
child.remove()
for file in file_data:
archive_node.add(
file['path'],
data={
'type': Node_type.DIRECTORY if file['type'] == 'd' else Node_type.FILE,
'config': config,
'repository': repository,
'archive': archive,
'file': file,
},
)
class Browse_tree(textual.widgets.Tree):
COMPONENT_CLASSES = textual.widgets.DirectoryTree.COMPONENT_CLASSES
DEFAULT_CSS = textual.widgets.DirectoryTree.DEFAULT_CSS
LOADING_DOT_INTERVAL_SECONDS = 0.3
def __init__(self):
super().__init__('root')
self.show_root = False
def render_label(self, node, base_style, style):
node_label = node._label.copy()
node_label.stylize(style)
if not self.is_mounted:
return node_label
if node._allow_expand:
icon = PATH_TYPE_ICONS.get(node.data['type'] if node.data else Node_type.FILE)
expanded_icon = PATH_TYPE_EXPANDED_ICONS.get(
node.data['type'] if node.data else Node_type.FILE, icon
)
prefix = (f'{expanded_icon} ' if node.is_expanded else f'{icon} ', base_style)
node_label.stylize_before(
self.get_component_rich_style("directory-tree--folder", partial=True)
)
else:
prefix = (f'{PATH_TYPE_ICONS.get(node.data["type"])} ', base_style)
node_label.stylize_before(
self.get_component_rich_style("directory-tree--file", partial=True),
)
node_label.highlight_regex(
r"\..+$",
self.get_component_rich_style("directory-tree--extension", partial=True),
)
if node_label.plain.startswith('.'):
node_label.stylize_before(
self.get_component_rich_style("directory-tree--hidden", partial=True)
)
return rich.text.Text.assemble(prefix, node_label)
def update_loading_dots(self, loading_node):
loading_node.label = (str(loading_node.label) + '.').replace('....', '')
def on_tree_node_expanded(self, event):
if event.node.data['type'] == Node_type.REPOSITORY and len(event.node.children) == 0:
loading_node = event.node.add_leaf(
'Loading archives...', data={'type': Node_type.LOADING}
)
timer = self.set_interval(
self.LOADING_DOT_INTERVAL_SECONDS,
functools.partial(self.update_loading_dots, loading_node=loading_node),
)
self.call_after_refresh(
functools.partial(
get_repository_archives,
self,
repository_node=event.node,
config=event.node.data['config'],
repository=event.node.data['repository'],
timer=timer,
)
)
elif event.node.data['type'] == Node_type.ARCHIVE and len(event.node.children) == 0:
loading_node = event.node.add_leaf('Loading files...', data={'type': Node_type.LOADING})
timer = self.set_interval(
self.LOADING_DOT_INTERVAL_SECONDS,
functools.partial(self.update_loading_dots, loading_node=loading_node),
)
self.call_after_refresh(
functools.partial(
get_archive_files,
self,
archive_node=event.node,
config=event.node.data['config'],
repository=event.node.data['repository'],
archive=event.node.data['archive'],
timer=timer,
)
)
def unexpand_path(config_path):
home_directory = os.path.expanduser('~')
if config_path.startswith(home_directory):
return config_path.replace(home_directory, '~')
return config_path
class Browse_log_handler(logging.Handler):
def __init__(self, app, log_widget):
self.app = app
self.log_widget = log_widget
super().__init__()
def emit(self, record):
message = self.format(record)
try:
worker = textual.worker.get_current_worker()
self.app.call_from_thread(self.log_widget.write, message)
except textual.worker.NoActiveWorker:
with contextlib.suppress(textual._context.NoActiveAppError):
self.log_widget.write(message)
class Rich_color_formatter(logging.Formatter):
def __init__(self, *args, **kwargs):
self.prefix = None
super().__init__(
'{prefix}{message}',
*args,
style='{',
**kwargs,
)
def format(self, record):
borgmatic.logger.add_custom_log_levels()
color = {
logging.CRITICAL: 'bright_red',
logging.ERROR: 'bright_red',
logging.WARNING: 'bright_yellow',
logging.ANSWER: 'bright_magenta',
logging.INFO: 'bright_green',
logging.DEBUG: 'bright_cyan',
}.get(record.levelno)
record.prefix = f'{self.prefix}: ' if self.prefix else ''
return f'[{color}]{super().format(record)}[/{color}]'
class Browse_app(textual.app.App):
BINDINGS = [
textual.binding.Binding(key='escape', action='quit', description='quit'),
textual.binding.Binding(
key='alt+m', action='command_palette', description='menu', show=False
),
]
COMMAND_PALETTE_BINDING = 'alt+m'
def __init__(self, configs):
self.configs = configs
super().__init__()
def compose(self):
tree = Browse_tree()
tree.styles.border = ('round', 'gray')
for config_path, config in self.configs.items():
config_node = tree.root.add(
unexpand_path(config_path), data={'type': Node_type.CONFIGURATION, 'config': config}
)
for repository in config['repositories']:
config_node.add(
repository['path'],
data={'type': Node_type.REPOSITORY, 'config': config, 'repository': repository},
)
first_child = tree.root.children[0]
tree.select_node(first_child)
tree.scroll_to_node(first_child)
yield textual.widgets.Header()
yield tree
log_widget = textual.widgets.RichLog(markup=True)
log_widget.styles.border = ('round', 'gray')
yield log_widget
yield textual.widgets.Footer()
logger = logging.getLogger()
handler = Browse_log_handler(self, log_widget)
handler.setFormatter(Rich_color_formatter())
logger.setLevel(min(handler.level for handler in logger.handlers))
logger.addHandler(handler)
# Remove the console log handler so it doesn't try to log all over our UI; we have our own
# log handler for surfacing logs within the UI.
with contextlib.suppress(StopIteration):
console_handler = next(
handler
for handler in logging.getLogger().handlers
if isinstance(handler, borgmatic.logger.Multi_stream_handler)
)
logger.removeHandler(console_handler)
def on_mount(self):
self.title = 'borgmatic browse'
self.theme = 'ansi-light'
tree = self.query_one(textual.widgets.Tree)
for child in tree.root.children:
child.expand()
def run_browse(
diff_arguments,
global_arguments,
configs,
):
'''
Run the "browse" action for the given repository.
'''
if not configs:
return
logging.getLogger('asyncio').setLevel(logging.WARNING)
app = Browse_app(configs)
app.run()
+17 -3
View File
@@ -31,9 +31,10 @@ ACTION_ALIASES = {
'transfer': [],
'break-lock': [],
'key': [],
'borg': [],
'recreate': [],
'diff': [],
'browse': [],
'borg': [],
}
@@ -1997,8 +1998,9 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915
diff_parser = action_parsers.add_parser(
'diff',
aliases=ACTION_ALIASES['diff'],
help='This command finds differences (file contents, user/group/mode) between archives',
description='This command finds differences (file contents, user/group/mode) between archives',
help='Find differences (file contents, user/group/mode) between archives',
description='Find differences (file contents, user/group/mode) between archives',
add_help=False,
)
diff_group = diff_parser.add_argument_group('diff arguments')
diff_group.add_argument(
@@ -2035,6 +2037,18 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915
action='store_true',
help='Run the diff according to borgmatic configured patterns (ie do not diff entire archives)',
)
diff_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
browse_parser = action_parsers.add_parser(
'browse',
aliases=ACTION_ALIASES['browse'],
help='Browse repositories, archives, and files in a console UI',
description='Browse repositories, archives, and files in a console UI',
add_help=False,
)
browse_group = browse_parser.add_argument_group('browse arguments')
browse_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
borg_parser = action_parsers.add_parser(
'borg',
aliases=ACTION_ALIASES['borg'],
+9 -1
View File
@@ -12,6 +12,7 @@ import ruamel.yaml
import borgmatic.actions.borg
import borgmatic.actions.break_lock
import borgmatic.actions.browse
import borgmatic.actions.change_passphrase
import borgmatic.actions.check
import borgmatic.actions.compact
@@ -825,8 +826,8 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
try:
if 'bootstrap' in arguments:
try:
# No configuration file is needed for bootstrap.
local_borg_version = borg_version.local_borg_version(
# No configuration file is needed for bootstrap.
{},
arguments['bootstrap'].local_path,
)
@@ -897,6 +898,13 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
return
if 'browse' in arguments:
borgmatic.actions.browse.run_browse(
arguments['browse'],
arguments['global'],
configs,
)
except (
CalledProcessError,
ValueError,
+2 -2
View File
@@ -31,7 +31,7 @@ changes work:
```bash
cd borgmatic
uv tool update-shell
uv tool install --editable .
uv tool install --editable .[dev]
```
Or to work on the [Apprise
@@ -39,7 +39,7 @@ hook](https://torsion.org/borgmatic/reference/configuration/monitoring/apprise/)
change that last line to:
```bash
uv tool install --editable .[Apprise]
uv tool install --editable .[dev,Apprise]
```
To get oriented with the borgmatic source code, have a look at the [source
+2
View File
@@ -21,6 +21,7 @@ dependencies = [
"packaging",
"requests",
"ruamel.yaml>0.15.0",
"textual",
]
[project.scripts]
@@ -30,6 +31,7 @@ validate-borgmatic-config = "borgmatic.commands.validate_config:main"
[project.optional-dependencies]
Apprise = ["apprise"]
dev = ["textual-dev"]
[project.urls]
Homepage = "https://torsion.org/borgmatic"
+2
View File
@@ -24,7 +24,9 @@ pyyaml>5.0.0
referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r test_requirements.in
requests==2.34.0 # via apprise, requests-oauthlib, -r test_requirements.in
requests-oauthlib==2.0.0 # via apprise, -r test_requirements.in
rich==15.0.0
rpds-py==0.30.0 # via jsonschema, referencing, -r test_requirements.in
ruamel-yaml>0.15.0
textual==8.2.5
typing-extensions==4.15.0 # via -r test_requirements.in
urllib3==2.7.0 # via requests, -r test_requirements.in