Compare commits
12 Commits
feature/pe
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bea3fe1b74 | |||
| 71f01edde0 | |||
| 25b3f9b20a | |||
| 75bfc570cf | |||
| 77fdb86991 | |||
| 048dcadaa2 | |||
| 90bb11e56a | |||
| f7460ad6af | |||
| 4b850bf0bf | |||
| 8cce3f34a9 | |||
| 38afc2583f | |||
| 4a0a5f1d93 |
17
README.md
@ -12,6 +12,7 @@ A modern HTTP client for GNOME, built with GTK 4 and libadwaita.
|
|||||||
- Environment variables with secure credential storage
|
- Environment variables with secure credential storage
|
||||||
- JavaScript preprocessing and postprocessing scripts
|
- JavaScript preprocessing and postprocessing scripts
|
||||||
- Export requests (cURL and more)
|
- Export requests (cURL and more)
|
||||||
|
- **Git-based collaboration** — projects are stored as plain JSON files, one per request, making it easy to share and version-control your API collections with a team
|
||||||
- GNOME-native UI
|
- GNOME-native UI
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@ -68,6 +69,22 @@ Store API keys, passwords, and tokens securely in GNOME Keyring with one-click e
|
|||||||
|
|
||||||
[Learn more about Sensitive Variables](https://git.bugsy.cz/beval/roster/wiki/Sensitive-Variables)
|
[Learn more about Sensitive Variables](https://git.bugsy.cz/beval/roster/wiki/Sensitive-Variables)
|
||||||
|
|
||||||
|
### Git-Based Collaboration
|
||||||
|
|
||||||
|
Every project and request is stored as a plain JSON file under `~/.local/share/cz.bugsy.roster/projects/`. This makes it straightforward to share your API collections with a team:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Put your Roster data directory under version control
|
||||||
|
cd ~/.local/share/cz.bugsy.roster/projects
|
||||||
|
git init
|
||||||
|
git remote add origin git@example.com:your-team/api-collections.git
|
||||||
|
git add .
|
||||||
|
git commit -m "Add API collections"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Team members can then clone the repository into their own data directory and get the full set of projects and requests instantly. Changes to requests show up as clean, reviewable diffs — each request is a separate file, so there are no merge conflicts from unrelated edits.
|
||||||
|
|
||||||
### JavaScript Automation
|
### JavaScript Automation
|
||||||
|
|
||||||
Use preprocessing and postprocessing scripts to:
|
Use preprocessing and postprocessing scripts to:
|
||||||
|
|||||||
@ -50,6 +50,18 @@
|
|||||||
</screenshots>
|
</screenshots>
|
||||||
|
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.11.0" date="2026-05-27">
|
||||||
|
<description translate="no">
|
||||||
|
<ul>
|
||||||
|
<li>Each request is now stored as a separate JSON file, making it easy to share and version-control API collections with Git</li>
|
||||||
|
<li>Existing data is migrated automatically on first launch; the original file is kept as a backup</li>
|
||||||
|
<li>New Backup section in Preferences: export project data to a folder, manually or automatically after every save</li>
|
||||||
|
<li>Method prefix (GET, POST, …) is no longer shown in request names — the colored chip already carries that information</li>
|
||||||
|
<li>Imported request names from OpenAPI and .http files no longer include the method prefix</li>
|
||||||
|
<li>Request names may now contain / and :</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.10.0" date="2026-05-21">
|
<release version="0.10.0" date="2026-05-21">
|
||||||
<description translate="no">
|
<description translate="no">
|
||||||
<p>Version 0.10.0 release</p>
|
<p>Version 0.10.0 release</p>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
project('roster',
|
project('roster',
|
||||||
version: '0.10.0',
|
version: '0.11.0',
|
||||||
meson_version: '>= 1.0.0',
|
meson_version: '>= 1.0.0',
|
||||||
default_options: [ 'warning_level=2', 'werror=false', ],
|
default_options: [ 'warning_level=2', 'werror=false', ],
|
||||||
)
|
)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 67 KiB |
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<template class="EnvironmentsDialog" parent="AdwDialog">
|
<template class="EnvironmentsDialog" parent="AdwDialog">
|
||||||
<property name="title">Manage Environments</property>
|
<property name="title">Manage Environments</property>
|
||||||
<property name="content-width">1100</property>
|
<property name="content-width">1200</property>
|
||||||
<property name="content-height">600</property>
|
<property name="content-height">600</property>
|
||||||
<property name="follows-content-size">false</property>
|
<property name="follows-content-size">false</property>
|
||||||
|
|
||||||
@ -18,7 +18,7 @@
|
|||||||
</child>
|
</child>
|
||||||
|
|
||||||
<property name="content">
|
<property name="content">
|
||||||
<object class="GtkScrolledWindow">
|
<object class="GtkScrolledWindow" id="scrolled_window">
|
||||||
<property name="vexpand">true</property>
|
<property name="vexpand">true</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="AdwClamp">
|
<object class="AdwClamp">
|
||||||
|
|||||||
@ -34,6 +34,7 @@ class EnvironmentsDialog(Adw.Dialog):
|
|||||||
|
|
||||||
table_container = Gtk.Template.Child()
|
table_container = Gtk.Template.Child()
|
||||||
add_environment_button = Gtk.Template.Child()
|
add_environment_button = Gtk.Template.Child()
|
||||||
|
scrolled_window = Gtk.Template.Child()
|
||||||
|
|
||||||
__gsignals__ = {
|
__gsignals__ = {
|
||||||
'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||||
@ -48,6 +49,10 @@ class EnvironmentsDialog(Adw.Dialog):
|
|||||||
self.header_row = None
|
self.header_row = None
|
||||||
self.data_rows = []
|
self.data_rows = []
|
||||||
self._populate_table()
|
self._populate_table()
|
||||||
|
self.connect('map', self._scroll_to_start)
|
||||||
|
|
||||||
|
def _scroll_to_start(self, widget):
|
||||||
|
self.scrolled_window.get_hadjustment().set_value(0)
|
||||||
|
|
||||||
def _populate_table(self):
|
def _populate_table(self):
|
||||||
"""Populate the transposed environment-variable table."""
|
"""Populate the transposed environment-variable table."""
|
||||||
|
|||||||
@ -172,7 +172,7 @@ def _parse_block(name: str, lines: list[str]) -> Optional[HttpFileRequest]:
|
|||||||
body = '\n'.join(body_lines)
|
body = '\n'.join(body_lines)
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
name = f"{method} {_shorten(url)}"
|
name = _shorten(url)
|
||||||
|
|
||||||
return HttpFileRequest(name=name, method=method, url=url, headers=headers, body=body)
|
return HttpFileRequest(name=name, method=method, url=url, headers=headers, body=body)
|
||||||
|
|
||||||
|
|||||||
@ -189,7 +189,7 @@ def _parse_v2(data: dict) -> OpenApiParseResult:
|
|||||||
def _make_op_v2(method, path, base_url, op_data, params, definitions) -> OpenApiOperation:
|
def _make_op_v2(method, path, base_url, op_data, params, definitions) -> OpenApiOperation:
|
||||||
op_id = op_data.get('operationId', '')
|
op_id = op_data.get('operationId', '')
|
||||||
description = op_data.get('summary') or op_data.get('description', '')
|
description = op_data.get('summary') or op_data.get('description', '')
|
||||||
name = op_id or f'{method} {path}'
|
name = op_id or path.lstrip('/')
|
||||||
|
|
||||||
url = base_url + path
|
url = base_url + path
|
||||||
url = _append_required_query(url, params)
|
url = _append_required_query(url, params)
|
||||||
@ -263,7 +263,7 @@ def _parse_v3(data: dict) -> OpenApiParseResult:
|
|||||||
def _make_op_v3(method, path, base_url, op_data, params, schemas) -> OpenApiOperation:
|
def _make_op_v3(method, path, base_url, op_data, params, schemas) -> OpenApiOperation:
|
||||||
op_id = op_data.get('operationId', '')
|
op_id = op_data.get('operationId', '')
|
||||||
description = op_data.get('summary') or op_data.get('description', '')
|
description = op_data.get('summary') or op_data.get('description', '')
|
||||||
name = op_id or f'{method} {path}'
|
name = op_id or path.lstrip('/')
|
||||||
|
|
||||||
url = base_url + path
|
url = base_url + path
|
||||||
url = _append_required_query(url, params)
|
url = _append_required_query(url, params)
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
<property name="title" translatable="yes">Preferences</property>
|
<property name="title" translatable="yes">Preferences</property>
|
||||||
<property name="modal">True</property>
|
<property name="modal">True</property>
|
||||||
<property name="default-width">600</property>
|
<property name="default-width">600</property>
|
||||||
<property name="default-height">400</property>
|
|
||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="AdwPreferencesPage">
|
<object class="AdwPreferencesPage">
|
||||||
|
|||||||
@ -48,6 +48,8 @@ def _unique_filename(base: str, exclude: set) -> str:
|
|||||||
class ProjectManager:
|
class ProjectManager:
|
||||||
"""Manages project and saved request persistence."""
|
"""Manages project and saved request persistence."""
|
||||||
|
|
||||||
|
_INDEX_FILE = '_index.json'
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.bugsy.roster'
|
self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.bugsy.roster'
|
||||||
self.projects_dir = self.data_dir / 'projects'
|
self.projects_dir = self.data_dir / 'projects'
|
||||||
@ -80,8 +82,29 @@ class ProjectManager:
|
|||||||
|
|
||||||
return self._load_from_dirs()
|
return self._load_from_dirs()
|
||||||
|
|
||||||
|
def _load_project_order(self) -> List[str]:
|
||||||
|
"""Load project order from _index.json. Returns list of project IDs."""
|
||||||
|
index_file = self.projects_dir / self._INDEX_FILE
|
||||||
|
if not index_file.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(index_file, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error loading project order: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _save_project_order(self, project_ids: List[str]):
|
||||||
|
"""Save project order to _index.json."""
|
||||||
|
index_file = self.projects_dir / self._INDEX_FILE
|
||||||
|
try:
|
||||||
|
with open(index_file, 'w') as f:
|
||||||
|
json.dump(project_ids, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error saving project order: %s", e)
|
||||||
|
|
||||||
def _load_from_dirs(self) -> List[Project]:
|
def _load_from_dirs(self) -> List[Project]:
|
||||||
projects = []
|
projects_by_id = {}
|
||||||
self._project_dirs.clear()
|
self._project_dirs.clear()
|
||||||
self._request_filenames.clear()
|
self._request_filenames.clear()
|
||||||
|
|
||||||
@ -145,12 +168,24 @@ class ProjectManager:
|
|||||||
environments=[Environment.from_dict(e) for e in meta.get('environments', [])],
|
environments=[Environment.from_dict(e) for e in meta.get('environments', [])],
|
||||||
sensitive_variables=meta.get('sensitive_variables', []),
|
sensitive_variables=meta.get('sensitive_variables', []),
|
||||||
)
|
)
|
||||||
projects.append(project)
|
projects_by_id[project.id] = project
|
||||||
self._project_dirs[project.id] = proj_dir.name
|
self._project_dirs[project.id] = proj_dir.name
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading project %s: %s", proj_dir, e)
|
logger.error("Error loading project %s: %s", proj_dir, e)
|
||||||
|
|
||||||
|
# Apply saved order, then append any projects not yet in the order file
|
||||||
|
ordered_ids = self._load_project_order()
|
||||||
|
projects = []
|
||||||
|
seen: set = set()
|
||||||
|
for pid in ordered_ids:
|
||||||
|
if pid in projects_by_id and pid not in seen:
|
||||||
|
projects.append(projects_by_id[pid])
|
||||||
|
seen.add(pid)
|
||||||
|
for pid, project in projects_by_id.items():
|
||||||
|
if pid not in seen:
|
||||||
|
projects.append(project)
|
||||||
|
|
||||||
self._projects_cache = projects
|
self._projects_cache = projects
|
||||||
return projects
|
return projects
|
||||||
|
|
||||||
@ -299,6 +334,10 @@ class ProjectManager:
|
|||||||
)
|
)
|
||||||
projects.append(project)
|
projects.append(project)
|
||||||
self._save_project(project)
|
self._save_project(project)
|
||||||
|
if not self._legacy_mode:
|
||||||
|
order = self._load_project_order()
|
||||||
|
order.append(project.id)
|
||||||
|
self._save_project_order(order)
|
||||||
return project
|
return project
|
||||||
|
|
||||||
def update_project(self, project_id: str, new_name: str = None, new_icon: str = None):
|
def update_project(self, project_id: str, new_name: str = None, new_icon: str = None):
|
||||||
@ -332,6 +371,9 @@ class ProjectManager:
|
|||||||
|
|
||||||
if self._legacy_mode:
|
if self._legacy_mode:
|
||||||
self._save_all_legacy()
|
self._save_all_legacy()
|
||||||
|
else:
|
||||||
|
order = self._load_project_order()
|
||||||
|
self._save_project_order([pid for pid in order if pid != project_id])
|
||||||
|
|
||||||
def on_secrets_deleted(success):
|
def on_secrets_deleted(success):
|
||||||
if callback:
|
if callback:
|
||||||
@ -394,6 +436,52 @@ class ProjectManager:
|
|||||||
self._save_project(p)
|
self._save_project(p)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
def reorder_project(self, project_id: str, direction: int):
|
||||||
|
"""Move a project up (direction=-1) or down (direction=+1)."""
|
||||||
|
projects = self.load_projects()
|
||||||
|
idx = next((i for i, p in enumerate(projects) if p.id == project_id), None)
|
||||||
|
if idx is None:
|
||||||
|
return
|
||||||
|
new_idx = idx + direction
|
||||||
|
if new_idx < 0 or new_idx >= len(projects):
|
||||||
|
return
|
||||||
|
projects[idx], projects[new_idx] = projects[new_idx], projects[idx]
|
||||||
|
self._projects_cache = projects
|
||||||
|
if not self._legacy_mode:
|
||||||
|
self._save_project_order([p.id for p in projects])
|
||||||
|
|
||||||
|
def reorder_request(self, project_id: str, request_id: str, direction: int):
|
||||||
|
"""Move a request up (direction=-1) or down (direction=+1) within its project."""
|
||||||
|
projects = self.load_projects()
|
||||||
|
for p in projects:
|
||||||
|
if p.id != project_id:
|
||||||
|
continue
|
||||||
|
idx = next((i for i, r in enumerate(p.requests) if r.id == request_id), None)
|
||||||
|
if idx is None:
|
||||||
|
return
|
||||||
|
new_idx = idx + direction
|
||||||
|
if new_idx < 0 or new_idx >= len(p.requests):
|
||||||
|
return
|
||||||
|
p.requests[idx], p.requests[new_idx] = p.requests[new_idx], p.requests[idx]
|
||||||
|
self._save_project(p)
|
||||||
|
return
|
||||||
|
|
||||||
|
def move_request_to_project(self, request_id: str, source_project_id: str, target_project_id: str):
|
||||||
|
"""Move a request from one project to another."""
|
||||||
|
projects = self.load_projects()
|
||||||
|
source = next((p for p in projects if p.id == source_project_id), None)
|
||||||
|
target = next((p for p in projects if p.id == target_project_id), None)
|
||||||
|
if not source or not target:
|
||||||
|
return
|
||||||
|
req = next((r for r in source.requests if r.id == request_id), None)
|
||||||
|
if not req:
|
||||||
|
return
|
||||||
|
# Write to target first so the file is never lost if source save fails
|
||||||
|
target.requests.append(req)
|
||||||
|
self._save_project(target)
|
||||||
|
source.requests = [r for r in source.requests if r.id != request_id]
|
||||||
|
self._save_project(source)
|
||||||
|
|
||||||
def add_environment(self, project_id: str, name: str) -> Environment:
|
def add_environment(self, project_id: str, name: str) -> Environment:
|
||||||
projects = self.load_projects()
|
projects = self.load_projects()
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|||||||
@ -4,6 +4,16 @@
|
|||||||
<requires lib="libadwaita" version="1.0"/>
|
<requires lib="libadwaita" version="1.0"/>
|
||||||
|
|
||||||
<menu id="project_menu">
|
<menu id="project_menu">
|
||||||
|
<section>
|
||||||
|
<item>
|
||||||
|
<attribute name="label">Move Up</attribute>
|
||||||
|
<attribute name="action">project.move-up</attribute>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<attribute name="label">Move Down</attribute>
|
||||||
|
<attribute name="action">project.move-down</attribute>
|
||||||
|
</item>
|
||||||
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<item>
|
<item>
|
||||||
<attribute name="label">Manage Environments</attribute>
|
<attribute name="label">Manage Environments</attribute>
|
||||||
|
|||||||
@ -41,11 +41,17 @@ class ProjectItem(Gtk.Box):
|
|||||||
'request-load-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
'request-load-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||||
'request-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
'request-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||||
'manage-environments-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
'manage-environments-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||||
|
'move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||||
|
'move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||||
|
'request-move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||||
|
'request-move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||||
|
'request-move-to-project-requested': (GObject.SIGNAL_RUN_FIRST, None, (object, GObject.TYPE_STRING)),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, project):
|
def __init__(self, project, other_projects=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.project = project
|
self.project = project
|
||||||
|
self.other_projects = other_projects or []
|
||||||
self.expanded = False
|
self.expanded = False
|
||||||
self._setup_actions()
|
self._setup_actions()
|
||||||
self._populate()
|
self._populate()
|
||||||
@ -59,6 +65,14 @@ class ProjectItem(Gtk.Box):
|
|||||||
"""Setup action group for menu."""
|
"""Setup action group for menu."""
|
||||||
actions = Gio.SimpleActionGroup.new()
|
actions = Gio.SimpleActionGroup.new()
|
||||||
|
|
||||||
|
self._move_up_action = Gio.SimpleAction.new("move-up", None)
|
||||||
|
self._move_up_action.connect("activate", lambda *_: self.emit('move-up-requested'))
|
||||||
|
actions.add_action(self._move_up_action)
|
||||||
|
|
||||||
|
self._move_down_action = Gio.SimpleAction.new("move-down", None)
|
||||||
|
self._move_down_action.connect("activate", lambda *_: self.emit('move-down-requested'))
|
||||||
|
actions.add_action(self._move_down_action)
|
||||||
|
|
||||||
action = Gio.SimpleAction.new("manage-environments", None)
|
action = Gio.SimpleAction.new("manage-environments", None)
|
||||||
action.connect("activate", lambda *_: self.emit('manage-environments-requested'))
|
action.connect("activate", lambda *_: self.emit('manage-environments-requested'))
|
||||||
actions.add_action(action)
|
actions.add_action(action)
|
||||||
@ -82,16 +96,25 @@ class ProjectItem(Gtk.Box):
|
|||||||
self.name_label.set_text(self.project.name)
|
self.name_label.set_text(self.project.name)
|
||||||
self.project_icon.set_from_icon_name(self.project.icon)
|
self.project_icon.set_from_icon_name(self.project.icon)
|
||||||
|
|
||||||
# Clear and add requests
|
|
||||||
while child := self.requests_listbox.get_first_child():
|
while child := self.requests_listbox.get_first_child():
|
||||||
self.requests_listbox.remove(child)
|
self.requests_listbox.remove(child)
|
||||||
|
|
||||||
for saved_request in self.project.requests:
|
total = len(self.project.requests)
|
||||||
|
for i, saved_request in enumerate(self.project.requests):
|
||||||
item = RequestItem(saved_request)
|
item = RequestItem(saved_request)
|
||||||
|
item.set_can_move_up(i > 0)
|
||||||
|
item.set_can_move_down(i < total - 1)
|
||||||
|
item.set_other_projects(self.other_projects)
|
||||||
item.connect('load-requested',
|
item.connect('load-requested',
|
||||||
lambda w, sr=saved_request: self.emit('request-load-requested', sr))
|
lambda w, sr=saved_request: self.emit('request-load-requested', sr))
|
||||||
item.connect('delete-requested',
|
item.connect('delete-requested',
|
||||||
lambda w, sr=saved_request: self.emit('request-delete-requested', sr))
|
lambda w, sr=saved_request: self.emit('request-delete-requested', sr))
|
||||||
|
item.connect('move-up-requested',
|
||||||
|
lambda w, sr=saved_request: self.emit('request-move-up-requested', sr))
|
||||||
|
item.connect('move-down-requested',
|
||||||
|
lambda w, sr=saved_request: self.emit('request-move-down-requested', sr))
|
||||||
|
item.connect('move-to-project-requested',
|
||||||
|
lambda w, pid, sr=saved_request: self.emit('request-move-to-project-requested', sr, pid))
|
||||||
self.requests_listbox.append(item)
|
self.requests_listbox.append(item)
|
||||||
|
|
||||||
def _on_header_clicked(self, gesture, n_press, x, y):
|
def _on_header_clicked(self, gesture, n_press, x, y):
|
||||||
@ -99,6 +122,14 @@ class ProjectItem(Gtk.Box):
|
|||||||
self.expanded = not self.expanded
|
self.expanded = not self.expanded
|
||||||
self.requests_revealer.set_reveal_child(self.expanded)
|
self.requests_revealer.set_reveal_child(self.expanded)
|
||||||
|
|
||||||
def refresh(self):
|
def set_can_move_up(self, can: bool):
|
||||||
"""Refresh from project data."""
|
self._move_up_action.set_enabled(can)
|
||||||
|
|
||||||
|
def set_can_move_down(self, can: bool):
|
||||||
|
self._move_down_action.set_enabled(can)
|
||||||
|
|
||||||
|
def refresh(self, other_projects=None):
|
||||||
|
"""Refresh from project data, optionally updating the other_projects list."""
|
||||||
|
if other_projects is not None:
|
||||||
|
self.other_projects = other_projects
|
||||||
self._populate()
|
self._populate()
|
||||||
|
|||||||
@ -27,12 +27,12 @@
|
|||||||
</child>
|
</child>
|
||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkButton" id="delete_button">
|
<object class="GtkMenuButton" id="menu_button">
|
||||||
<property name="icon-name">edit-delete-symbolic</property>
|
<property name="icon-name">view-more-symbolic</property>
|
||||||
<property name="tooltip-text">Delete request</property>
|
<property name="tooltip-text">Options</property>
|
||||||
<signal name="clicked" handler="on_delete_clicked"/>
|
|
||||||
<style>
|
<style>
|
||||||
<class name="flat"/>
|
<class name="flat"/>
|
||||||
|
<class name="request-menu-button"/>
|
||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from gi.repository import Gtk, GObject
|
from gi.repository import Gtk, GObject, Gio, GLib
|
||||||
|
|
||||||
_METHOD_CSS = {
|
_METHOD_CSS = {
|
||||||
'GET': 'accent',
|
'GET': 'accent',
|
||||||
@ -37,10 +37,14 @@ class RequestItem(Gtk.Box):
|
|||||||
|
|
||||||
method_label = Gtk.Template.Child()
|
method_label = Gtk.Template.Child()
|
||||||
name_label = Gtk.Template.Child()
|
name_label = Gtk.Template.Child()
|
||||||
|
menu_button = Gtk.Template.Child()
|
||||||
|
|
||||||
__gsignals__ = {
|
__gsignals__ = {
|
||||||
'load-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
'load-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||||
'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||||
|
'move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||||
|
'move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||||
|
'move-to-project-requested': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_STRING,)),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, saved_request):
|
def __init__(self, saved_request):
|
||||||
@ -49,18 +53,99 @@ class RequestItem(Gtk.Box):
|
|||||||
method = saved_request.request.method
|
method = saved_request.request.method
|
||||||
self.method_label.set_text(method)
|
self.method_label.set_text(method)
|
||||||
self.method_label.add_css_class(_METHOD_CSS.get(method, 'dim-label'))
|
self.method_label.add_css_class(_METHOD_CSS.get(method, 'dim-label'))
|
||||||
self.name_label.set_text(saved_request.name)
|
display_name = saved_request.name
|
||||||
|
for prefix in _METHOD_CSS:
|
||||||
|
if display_name.upper().startswith(prefix + ' '):
|
||||||
|
display_name = display_name[len(prefix) + 1:]
|
||||||
|
break
|
||||||
|
self.name_label.set_text(display_name)
|
||||||
|
|
||||||
# Add click gesture for loading
|
self._setup_menu()
|
||||||
|
|
||||||
|
# Click gesture for loading
|
||||||
gesture = Gtk.GestureClick.new()
|
gesture = Gtk.GestureClick.new()
|
||||||
gesture.connect('released', self._on_clicked)
|
gesture.connect('released', self._on_clicked)
|
||||||
self.add_controller(gesture)
|
self.add_controller(gesture)
|
||||||
|
|
||||||
|
# Show/hide menu button on hover
|
||||||
|
motion = Gtk.EventControllerMotion.new()
|
||||||
|
motion.connect('enter', self._on_hover_enter)
|
||||||
|
motion.connect('leave', self._on_hover_leave)
|
||||||
|
self.add_controller(motion)
|
||||||
|
|
||||||
|
def _setup_menu(self):
|
||||||
|
actions = Gio.SimpleActionGroup.new()
|
||||||
|
|
||||||
|
self._move_up_action = Gio.SimpleAction.new("move-up", None)
|
||||||
|
self._move_up_action.connect("activate", lambda *_: self.emit('move-up-requested'))
|
||||||
|
actions.add_action(self._move_up_action)
|
||||||
|
|
||||||
|
self._move_down_action = Gio.SimpleAction.new("move-down", None)
|
||||||
|
self._move_down_action.connect("activate", lambda *_: self.emit('move-down-requested'))
|
||||||
|
actions.add_action(self._move_down_action)
|
||||||
|
|
||||||
|
move_to = Gio.SimpleAction.new("move-to-project", GLib.VariantType.new("s"))
|
||||||
|
move_to.connect("activate", self._on_move_to_project_activated)
|
||||||
|
actions.add_action(move_to)
|
||||||
|
|
||||||
|
delete = Gio.SimpleAction.new("delete", None)
|
||||||
|
delete.connect("activate", lambda *_: self.emit('delete-requested'))
|
||||||
|
actions.add_action(delete)
|
||||||
|
|
||||||
|
self.insert_action_group("request", actions)
|
||||||
|
|
||||||
|
# Build menu model
|
||||||
|
menu = Gio.Menu()
|
||||||
|
|
||||||
|
reorder_section = Gio.Menu()
|
||||||
|
reorder_section.append("Move Up", "request.move-up")
|
||||||
|
reorder_section.append("Move Down", "request.move-down")
|
||||||
|
menu.append_section(None, reorder_section)
|
||||||
|
|
||||||
|
self._move_to_submenu = Gio.Menu()
|
||||||
|
move_section = Gio.Menu()
|
||||||
|
move_section.append_submenu("Move to Project", self._move_to_submenu)
|
||||||
|
menu.append_section(None, move_section)
|
||||||
|
|
||||||
|
delete_section = Gio.Menu()
|
||||||
|
delete_section.append("Delete", "request.delete")
|
||||||
|
menu.append_section(None, delete_section)
|
||||||
|
|
||||||
|
self.menu_button.set_menu_model(menu)
|
||||||
|
|
||||||
|
# Hidden by default; shown on hover via EventControllerMotion.
|
||||||
|
# When the popover closes, hide the button again.
|
||||||
|
self.menu_button.set_opacity(0.0)
|
||||||
|
popover = self.menu_button.get_popover()
|
||||||
|
if popover:
|
||||||
|
popover.connect('closed', lambda p: self.menu_button.set_opacity(0.0))
|
||||||
|
|
||||||
|
def _on_move_to_project_activated(self, action, param):
|
||||||
|
self.emit('move-to-project-requested', param.get_string())
|
||||||
|
|
||||||
def _on_clicked(self, gesture, n_press, x, y):
|
def _on_clicked(self, gesture, n_press, x, y):
|
||||||
"""Handle click to load request."""
|
"""Load request on row click (but not if the menu button was clicked)."""
|
||||||
|
alloc = self.menu_button.get_allocation()
|
||||||
|
if alloc.x <= x <= alloc.x + alloc.width and alloc.y <= y <= alloc.y + alloc.height:
|
||||||
|
return
|
||||||
self.emit('load-requested')
|
self.emit('load-requested')
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
def _on_hover_enter(self, controller, x, y):
|
||||||
def on_delete_clicked(self, button):
|
self.menu_button.set_opacity(1.0)
|
||||||
"""Handle delete button click."""
|
|
||||||
self.emit('delete-requested')
|
def _on_hover_leave(self, controller):
|
||||||
|
popover = self.menu_button.get_popover()
|
||||||
|
if not (popover and popover.get_visible()):
|
||||||
|
self.menu_button.set_opacity(0.0)
|
||||||
|
|
||||||
|
def set_can_move_up(self, can: bool):
|
||||||
|
self._move_up_action.set_enabled(can)
|
||||||
|
|
||||||
|
def set_can_move_down(self, can: bool):
|
||||||
|
self._move_down_action.set_enabled(can)
|
||||||
|
|
||||||
|
def set_other_projects(self, projects):
|
||||||
|
"""Rebuild the 'Move to Project' submenu from the given project list."""
|
||||||
|
self._move_to_submenu.remove_all()
|
||||||
|
for p in projects:
|
||||||
|
self._move_to_submenu.append(p.name, f"request.move-to-project::{p.id}")
|
||||||
|
|||||||
@ -952,7 +952,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
return False, f"Request name is too long (max {REQUEST_NAME_MAX_LENGTH} characters)"
|
return False, f"Request name is too long (max {REQUEST_NAME_MAX_LENGTH} characters)"
|
||||||
|
|
||||||
# Check for invalid characters (file system unsafe characters)
|
# Check for invalid characters (file system unsafe characters)
|
||||||
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0']
|
invalid_chars = ['\\', '*', '?', '"', '<', '>', '|', '\0']
|
||||||
for char in invalid_chars:
|
for char in invalid_chars:
|
||||||
if char in name:
|
if char in name:
|
||||||
return False, f"Request name cannot contain '{char}'"
|
return False, f"Request name cannot contain '{char}'"
|
||||||
@ -961,20 +961,45 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
def _load_projects(self) -> None:
|
def _load_projects(self) -> None:
|
||||||
"""Load and display projects."""
|
"""Load and display projects."""
|
||||||
|
# Remember which projects are currently expanded.
|
||||||
|
# GtkListBox wraps each child in a GtkListBoxRow, so we call get_child()
|
||||||
|
# on the row to reach the actual ProjectItem widget.
|
||||||
|
expanded_ids = set()
|
||||||
|
row = self.projects_listbox.get_first_child()
|
||||||
|
while row:
|
||||||
|
project_item = row.get_child()
|
||||||
|
if isinstance(project_item, ProjectItem) and project_item.expanded:
|
||||||
|
expanded_ids.add(project_item.project.id)
|
||||||
|
row = row.get_next_sibling()
|
||||||
|
|
||||||
# Clear existing
|
# Clear existing
|
||||||
while child := self.projects_listbox.get_first_child():
|
while child := self.projects_listbox.get_first_child():
|
||||||
self.projects_listbox.remove(child)
|
self.projects_listbox.remove(child)
|
||||||
|
|
||||||
# Load and populate
|
# Load and populate
|
||||||
projects = self.project_manager.load_projects()
|
projects = self.project_manager.load_projects()
|
||||||
for project in projects:
|
total = len(projects)
|
||||||
item = ProjectItem(project)
|
for i, project in enumerate(projects):
|
||||||
|
other_projects = [p for p in projects if p.id != project.id]
|
||||||
|
item = ProjectItem(project, other_projects)
|
||||||
|
item.set_can_move_up(i > 0)
|
||||||
|
item.set_can_move_down(i < total - 1)
|
||||||
item.connect('edit-requested', self._on_project_edit, project)
|
item.connect('edit-requested', self._on_project_edit, project)
|
||||||
item.connect('delete-requested', self._on_project_delete, project)
|
item.connect('delete-requested', self._on_project_delete, project)
|
||||||
item.connect('add-request-requested', self._on_add_to_project, project)
|
item.connect('add-request-requested', self._on_add_to_project, project)
|
||||||
item.connect('request-load-requested', self._on_load_request)
|
item.connect('request-load-requested', self._on_load_request)
|
||||||
item.connect('request-delete-requested', self._on_delete_request, project)
|
item.connect('request-delete-requested', self._on_delete_request, project)
|
||||||
item.connect('manage-environments-requested', self._on_manage_environments, project)
|
item.connect('manage-environments-requested', self._on_manage_environments, project)
|
||||||
|
item.connect('move-up-requested', self._on_project_move_up, project)
|
||||||
|
item.connect('move-down-requested', self._on_project_move_down, project)
|
||||||
|
item.connect('request-move-up-requested', self._on_request_move_up, project)
|
||||||
|
item.connect('request-move-down-requested', self._on_request_move_down, project)
|
||||||
|
item.connect('request-move-to-project-requested', self._on_request_move_to_project, project)
|
||||||
|
if project.id in expanded_ids:
|
||||||
|
item.expanded = True
|
||||||
|
item.requests_revealer.set_transition_duration(0)
|
||||||
|
item.requests_revealer.set_reveal_child(True)
|
||||||
|
GLib.idle_add(lambda r=item.requests_revealer: (r.set_transition_duration(250), False)[1])
|
||||||
self.projects_listbox.append(item)
|
self.projects_listbox.append(item)
|
||||||
|
|
||||||
def on_add_project_clicked(self, button):
|
def on_add_project_clicked(self, button):
|
||||||
@ -1112,6 +1137,33 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
dialog.connect('environments-updated', on_environments_updated)
|
dialog.connect('environments-updated', on_environments_updated)
|
||||||
dialog.present(self)
|
dialog.present(self)
|
||||||
|
|
||||||
|
def _on_project_move_up(self, widget, project):
|
||||||
|
self.project_manager.reorder_project(project.id, -1)
|
||||||
|
self._load_projects()
|
||||||
|
|
||||||
|
def _on_project_move_down(self, widget, project):
|
||||||
|
self.project_manager.reorder_project(project.id, +1)
|
||||||
|
self._load_projects()
|
||||||
|
|
||||||
|
def _on_request_move_up(self, widget, saved_request, project):
|
||||||
|
self.project_manager.reorder_request(project.id, saved_request.id, -1)
|
||||||
|
self._load_projects()
|
||||||
|
|
||||||
|
def _on_request_move_down(self, widget, saved_request, project):
|
||||||
|
self.project_manager.reorder_request(project.id, saved_request.id, +1)
|
||||||
|
self._load_projects()
|
||||||
|
|
||||||
|
def _on_request_move_to_project(self, widget, saved_request, target_project_id, source_project):
|
||||||
|
self.project_manager.move_request_to_project(
|
||||||
|
saved_request.id, source_project.id, target_project_id
|
||||||
|
)
|
||||||
|
self._load_projects()
|
||||||
|
# Find target project name for toast
|
||||||
|
projects = self.project_manager.load_projects()
|
||||||
|
target = next((p for p in projects if p.id == target_project_id), None)
|
||||||
|
if target:
|
||||||
|
self._show_toast(f"Moved to '{target.name}'")
|
||||||
|
|
||||||
@Gtk.Template.Callback()
|
@Gtk.Template.Callback()
|
||||||
def on_save_request_clicked(self, button):
|
def on_save_request_clicked(self, button):
|
||||||
"""Save current request to a project."""
|
"""Save current request to a project."""
|
||||||
|
|||||||