Skip to content

Commit 13a4ee5

Browse files
committed
Add Home dashboard and usability polish
* New Home tab with overview text, live backend-readiness status, and quick-nav buttons to jump into other tabs. * Main window registers Ctrl+1..5 shortcuts for tab navigation and surfaces worker log lines on the status bar. * JSON editor accepts drag-and-drop of .json files, binds Ctrl+O / Ctrl+S / Ctrl+R to load / save / run, and remembers the last directory via QSettings.
1 parent e0db599 commit 13a4ee5

7 files changed

Lines changed: 217 additions & 12 deletions

File tree

automation_file/ui/log_widget.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44

55
import time
66

7+
from PySide6.QtCore import Signal
78
from PySide6.QtGui import QTextCursor
89
from PySide6.QtWidgets import QPlainTextEdit
910

1011

1112
class LogPanel(QPlainTextEdit):
1213
"""Read-only text panel that timestamps and appends log lines."""
1314

15+
message_appended = Signal(str)
16+
1417
def __init__(self) -> None:
1518
super().__init__()
1619
self.setReadOnly(True)
@@ -21,3 +24,4 @@ def append_line(self, message: str) -> None:
2124
stamp = time.strftime("%H:%M:%S")
2225
self.appendPlainText(f"[{stamp}] {message}")
2326
self.moveCursor(QTextCursor.MoveOperation.End)
27+
self.message_appended.emit(message)

automation_file/ui/main_window.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
from __future__ import annotations
44

5-
from PySide6.QtCore import QThreadPool
5+
from PySide6.QtCore import Qt, QThreadPool
6+
from PySide6.QtGui import QKeySequence, QShortcut
67
from PySide6.QtWidgets import QMainWindow, QSplitter, QTabWidget, QVBoxLayout, QWidget
78

89
from automation_file.logging_config import file_automation_logger
910
from automation_file.ui.log_widget import LogPanel
1011
from automation_file.ui.tabs import (
12+
HomeTab,
1113
JSONEditorTab,
1214
LocalOpsTab,
1315
ServerTab,
@@ -16,6 +18,7 @@
1618

1719
_WINDOW_TITLE = "automation_file"
1820
_DEFAULT_SIZE = (1100, 780)
21+
_STATUS_DEFAULT = "Ready"
1922

2023

2124
class MainWindow(QMainWindow):
@@ -28,17 +31,21 @@ def __init__(self) -> None:
2831

2932
self._pool = QThreadPool.globalInstance()
3033
self._log = LogPanel()
34+
self._log.message_appended.connect(self._on_log_message)
3135

32-
tabs = QTabWidget()
33-
tabs.addTab(LocalOpsTab(self._log, self._pool), "Local")
34-
tabs.addTab(TransferTab(self._log, self._pool), "Transfer")
35-
tabs.addTab(JSONEditorTab(self._log, self._pool), "JSON actions")
36+
self._tabs = QTabWidget()
37+
self._home_tab = HomeTab(self._log, self._pool)
38+
self._home_tab.navigate_to_tab.connect(self._focus_tab_by_name)
39+
self._tabs.addTab(self._home_tab, "Home")
40+
self._tabs.addTab(LocalOpsTab(self._log, self._pool), "Local")
41+
self._tabs.addTab(TransferTab(self._log, self._pool), "Transfer")
42+
self._tabs.addTab(JSONEditorTab(self._log, self._pool), "JSON actions")
3643
self._server_tab = ServerTab(self._log, self._pool)
37-
tabs.addTab(self._server_tab, "Servers")
44+
self._tabs.addTab(self._server_tab, "Servers")
3845

3946
splitter = QSplitter()
40-
splitter.setOrientation(splitter.orientation().Vertical)
41-
splitter.addWidget(tabs)
47+
splitter.setOrientation(Qt.Orientation.Vertical)
48+
splitter.addWidget(self._tabs)
4249
splitter.addWidget(self._log)
4350
splitter.setStretchFactor(0, 4)
4451
splitter.setStretchFactor(1, 1)
@@ -49,9 +56,24 @@ def __init__(self) -> None:
4956
layout.addWidget(splitter)
5057
self.setCentralWidget(container)
5158

52-
self.statusBar().showMessage("Ready")
59+
self._register_shortcuts()
60+
self.statusBar().showMessage(_STATUS_DEFAULT)
5361
file_automation_logger.info("ui: main window constructed")
5462

63+
def _register_shortcuts(self) -> None:
64+
for index in range(self._tabs.count()):
65+
shortcut = QShortcut(QKeySequence(f"Ctrl+{index + 1}"), self)
66+
shortcut.activated.connect(lambda i=index: self._tabs.setCurrentIndex(i))
67+
68+
def _focus_tab_by_name(self, name: str) -> None:
69+
for index in range(self._tabs.count()):
70+
if self._tabs.tabText(index) == name:
71+
self._tabs.setCurrentIndex(index)
72+
return
73+
74+
def _on_log_message(self, message: str) -> None:
75+
self.statusBar().showMessage(message, 5000)
76+
5577
def closeEvent(self, event) -> None: # noqa: N802 — Qt override
5678
self._server_tab.closeEvent(event)
5779
super().closeEvent(event)

automation_file/ui/tabs/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from automation_file.ui.tabs.azure_tab import AzureBlobTab
66
from automation_file.ui.tabs.drive_tab import GoogleDriveTab
77
from automation_file.ui.tabs.dropbox_tab import DropboxTab
8+
from automation_file.ui.tabs.home_tab import HomeTab
89
from automation_file.ui.tabs.http_tab import HTTPDownloadTab
910
from automation_file.ui.tabs.json_editor_tab import JSONEditorTab
1011
from automation_file.ui.tabs.local_tab import LocalOpsTab
@@ -18,6 +19,7 @@
1819
"DropboxTab",
1920
"GoogleDriveTab",
2021
"HTTPDownloadTab",
22+
"HomeTab",
2123
"JSONEditorTab",
2224
"LocalOpsTab",
2325
"S3Tab",
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Landing dashboard — overview, backend readiness, quick actions."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable
6+
from typing import NamedTuple
7+
8+
from PySide6.QtCore import QThreadPool, QTimer, Signal
9+
from PySide6.QtWidgets import (
10+
QFormLayout,
11+
QGroupBox,
12+
QHBoxLayout,
13+
QLabel,
14+
QVBoxLayout,
15+
)
16+
17+
from automation_file.remote.azure_blob.client import azure_blob_instance
18+
from automation_file.remote.dropbox_api.client import dropbox_instance
19+
from automation_file.remote.google_drive.client import driver_instance
20+
from automation_file.remote.s3.client import s3_instance
21+
from automation_file.remote.sftp.client import sftp_instance
22+
from automation_file.ui.log_widget import LogPanel
23+
from automation_file.ui.tabs.base import BaseTab
24+
25+
_REFRESH_INTERVAL_MS = 2000
26+
27+
28+
class _BackendProbe(NamedTuple):
29+
label: str
30+
is_ready: Callable[[], bool]
31+
32+
33+
_BACKENDS: tuple[_BackendProbe, ...] = (
34+
_BackendProbe("Google Drive", lambda: driver_instance.service is not None),
35+
_BackendProbe("Amazon S3", lambda: s3_instance.client is not None),
36+
_BackendProbe("Azure Blob", lambda: azure_blob_instance.service is not None),
37+
_BackendProbe("Dropbox", lambda: dropbox_instance.client is not None),
38+
_BackendProbe("SFTP", lambda: getattr(sftp_instance, "_sftp", None) is not None),
39+
)
40+
41+
42+
class HomeTab(BaseTab):
43+
"""Dashboard with overview text, backend status, and quick-nav buttons."""
44+
45+
navigate_to_tab = Signal(str)
46+
47+
def __init__(self, log: LogPanel, pool: QThreadPool) -> None:
48+
super().__init__(log, pool)
49+
self._status_labels: dict[str, QLabel] = {}
50+
51+
root = QVBoxLayout(self)
52+
root.addWidget(self._overview_group())
53+
row = QHBoxLayout()
54+
row.addWidget(self._status_group(), 1)
55+
row.addWidget(self._actions_group(), 1)
56+
root.addLayout(row)
57+
root.addStretch()
58+
59+
self._refresh_status()
60+
self._timer = QTimer(self)
61+
self._timer.setInterval(_REFRESH_INTERVAL_MS)
62+
self._timer.timeout.connect(self._refresh_status)
63+
self._timer.start()
64+
65+
def _overview_group(self) -> QGroupBox:
66+
box = QGroupBox("automation_file")
67+
layout = QVBoxLayout(box)
68+
headline = QLabel(
69+
"Automate local and remote file work through a shared registry of "
70+
"<code>FA_*</code> actions."
71+
)
72+
headline.setWordWrap(True)
73+
layout.addWidget(headline)
74+
details = QLabel(
75+
"Use <b>Local</b> for direct filesystem / ZIP operations, "
76+
"<b>Transfer</b> to move bytes to cloud backends (HTTP, Drive, S3, "
77+
"Azure, Dropbox, SFTP), and <b>JSON actions</b> for visual editing "
78+
"of reusable action lists. <b>Servers</b> exposes the same registry "
79+
"over localhost TCP or HTTP."
80+
)
81+
details.setWordWrap(True)
82+
layout.addWidget(details)
83+
return box
84+
85+
def _status_group(self) -> QGroupBox:
86+
box = QGroupBox("Remote backends")
87+
form = QFormLayout(box)
88+
for probe in _BACKENDS:
89+
label = QLabel("—")
90+
self._status_labels[probe.label] = label
91+
form.addRow(probe.label, label)
92+
return box
93+
94+
def _actions_group(self) -> QGroupBox:
95+
box = QGroupBox("Jump to…")
96+
layout = QVBoxLayout(box)
97+
for tab_name in ("Local", "Transfer", "JSON actions", "Servers"):
98+
button = self.make_button(tab_name, self._emit_nav(tab_name))
99+
layout.addWidget(button)
100+
layout.addStretch()
101+
return box
102+
103+
def _emit_nav(self, tab_name: str) -> Callable[[], None]:
104+
return lambda: self.navigate_to_tab.emit(tab_name)
105+
106+
def _refresh_status(self) -> None:
107+
for probe in _BACKENDS:
108+
label = self._status_labels.get(probe.label)
109+
if label is None:
110+
continue
111+
try:
112+
ready = bool(probe.is_ready())
113+
except Exception: # pylint: disable=broad-except
114+
ready = False
115+
label.setText("Ready" if ready else "Not initialised")
116+
label.setStyleSheet("color: #2f8f3f;" if ready else "color: #888;")

automation_file/ui/tabs/json_editor_tab.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
import inspect
1212
import json
1313
from collections.abc import Callable
14+
from pathlib import Path
1415
from typing import Any
1516

16-
from PySide6.QtCore import Qt
17+
from PySide6.QtCore import QSettings, Qt
18+
from PySide6.QtGui import QDragEnterEvent, QDropEvent, QKeySequence, QShortcut
1719
from PySide6.QtWidgets import (
1820
QCheckBox,
1921
QComboBox,
@@ -45,6 +47,9 @@
4547

4648
_PATH_HINT_SUBSTRINGS = ("path", "_dir", "_file", "directory", "filename", "target")
4749
_SECRET_HINT_SUBSTRINGS = ("password", "secret", "token", "credential")
50+
_SETTINGS_ORG = "automation_file"
51+
_SETTINGS_APP = "ui"
52+
_LAST_JSON_DIR_KEY = "json_editor/last_dir"
4853

4954

5055
def _is_path_like(name: str) -> bool:
@@ -237,6 +242,8 @@ def __init__(self, log, pool) -> None:
237242
self._current_form: _ActionForm | None = None
238243
self._current_form_row: int = -1
239244
self._suppress_sync = False
245+
self._settings = QSettings(_SETTINGS_ORG, _SETTINGS_APP)
246+
self.setAcceptDrops(True)
240247

241248
self._action_list = QListWidget()
242249
self._action_list.currentRowChanged.connect(self._on_row_changed)
@@ -261,6 +268,8 @@ def __init__(self, log, pool) -> None:
261268
root.addWidget(splitter)
262269
root.addWidget(self._build_run_bar())
263270

271+
self._register_shortcuts()
272+
264273
def _build_toolbar(self) -> QWidget:
265274
toolbar = QWidget()
266275
row = QHBoxLayout(toolbar)
@@ -431,9 +440,15 @@ def _unpack_action(action: list[Any]) -> tuple[str, dict[str, Any]]:
431440
return name, {}
432441

433442
def _on_load(self) -> None:
434-
path, _ = QFileDialog.getOpenFileName(self, "Load action JSON", filter="JSON (*.json)")
443+
start_dir = str(self._settings.value(_LAST_JSON_DIR_KEY, ""))
444+
path, _ = QFileDialog.getOpenFileName(
445+
self, "Load action JSON", start_dir, filter="JSON (*.json)"
446+
)
435447
if not path:
436448
return
449+
self._load_path(path)
450+
451+
def _load_path(self, path: str) -> None:
437452
try:
438453
with open(path, encoding="utf-8") as fp:
439454
data = json.load(fp)
@@ -444,12 +459,18 @@ def _on_load(self) -> None:
444459
self._log.append_line("load error: top-level JSON must be an array")
445460
return
446461
self._actions = data
462+
self._settings.setValue(_LAST_JSON_DIR_KEY, str(Path(path).parent))
463+
self._clear_current_form()
447464
self._refresh_list(select=0 if data else None)
448465
self._sync_raw_from_model()
466+
self._log.append_line(f"loaded {len(data)} actions from {path}")
449467

450468
def _on_save(self) -> None:
451469
self._commit_current_form()
452-
path, _ = QFileDialog.getSaveFileName(self, "Save action JSON", filter="JSON (*.json)")
470+
start_dir = str(self._settings.value(_LAST_JSON_DIR_KEY, ""))
471+
path, _ = QFileDialog.getSaveFileName(
472+
self, "Save action JSON", start_dir, filter="JSON (*.json)"
473+
)
453474
if not path:
454475
return
455476
try:
@@ -458,6 +479,7 @@ def _on_save(self) -> None:
458479
except OSError as error:
459480
self._log.append_line(f"save error: {error}")
460481
return
482+
self._settings.setValue(_LAST_JSON_DIR_KEY, str(Path(path).parent))
461483
self._log.append_line(f"saved {len(self._actions)} actions to {path}")
462484

463485
def _on_clear(self) -> None:
@@ -536,3 +558,38 @@ def _on_validate(self) -> None:
536558
f"validate_action({len(actions)})",
537559
kwargs={"action_list": actions},
538560
)
561+
562+
def _register_shortcuts(self) -> None:
563+
for keys, handler in (
564+
("Ctrl+O", self._on_load),
565+
("Ctrl+S", self._on_save),
566+
("Ctrl+R", self._on_run),
567+
):
568+
shortcut = QShortcut(QKeySequence(keys), self)
569+
shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
570+
shortcut.activated.connect(handler)
571+
572+
def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802 — Qt override
573+
if self._is_json_drop(event):
574+
event.acceptProposedAction()
575+
return
576+
event.ignore()
577+
578+
def dropEvent(self, event: QDropEvent) -> None: # noqa: N802 — Qt override
579+
if not self._is_json_drop(event):
580+
event.ignore()
581+
return
582+
url = event.mimeData().urls()[0]
583+
self._load_path(url.toLocalFile())
584+
event.acceptProposedAction()
585+
586+
@staticmethod
587+
def _is_json_drop(event: QDragEnterEvent | QDropEvent) -> bool:
588+
mime = event.mimeData()
589+
if not mime.hasUrls():
590+
return False
591+
urls = mime.urls()
592+
if not urls:
593+
return False
594+
local = urls[0].toLocalFile()
595+
return bool(local) and local.lower().endswith(".json")

docs/source/api/ui.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ Tabs
3838
.. automodule:: automation_file.ui.tabs.base
3939
:members:
4040

41+
.. automodule:: automation_file.ui.tabs.home_tab
42+
:members:
43+
4144
.. automodule:: automation_file.ui.tabs.local_tab
4245
:members:
4346

tests/test_ui_smoke.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def test_main_window_constructs(qt_app) -> None:
5454
"JSONEditorTab",
5555
"ServerTab",
5656
"TransferTab",
57+
"HomeTab",
5758
],
5859
)
5960
def test_each_tab_constructs(qt_app, tab_name: str) -> None:

0 commit comments

Comments
 (0)