Compare commits
No commits in common. "master" and "feature/per-file-storage" have entirely different histories.
master
...
feature/pe
17
README.md
@ -12,7 +12,6 @@ A modern HTTP client for GNOME, built with GTK 4 and libadwaita.
|
||||
- Environment variables with secure credential storage
|
||||
- JavaScript preprocessing and postprocessing scripts
|
||||
- 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
|
||||
|
||||
## Screenshots
|
||||
@ -69,22 +68,6 @@ 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)
|
||||
|
||||
### 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
|
||||
|
||||
Use preprocessing and postprocessing scripts to:
|
||||
|
||||
@ -50,18 +50,6 @@
|
||||
</screenshots>
|
||||
|
||||
<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">
|
||||
<description translate="no">
|
||||
<p>Version 0.10.0 release</p>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
project('roster',
|
||||
version: '0.11.0',
|
||||
version: '0.10.0',
|
||||
meson_version: '>= 1.0.0',
|
||||
default_options: [ 'warning_level=2', 'werror=false', ],
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 71 KiB |
@ -5,7 +5,7 @@
|
||||
|
||||
<template class="EnvironmentsDialog" parent="AdwDialog">
|
||||
<property name="title">Manage Environments</property>
|
||||
<property name="content-width">1200</property>
|
||||
<property name="content-width">1100</property>
|
||||
<property name="content-height">600</property>
|
||||
<property name="follows-content-size">false</property>
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
</child>
|
||||
|
||||
<property name="content">
|
||||
<object class="GtkScrolledWindow" id="scrolled_window">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwClamp">
|
||||
|
||||
@ -34,7 +34,6 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
|
||||
table_container = Gtk.Template.Child()
|
||||
add_environment_button = Gtk.Template.Child()
|
||||
scrolled_window = Gtk.Template.Child()
|
||||
|
||||
__gsignals__ = {
|
||||
'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()),
|
||||
@ -49,10 +48,6 @@ class EnvironmentsDialog(Adw.Dialog):
|
||||
self.header_row = None
|
||||
self.data_rows = []
|
||||
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):
|
||||
"""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)
|
||||
|
||||
if not name:
|
||||
name = _shorten(url)
|
||||
name = f"{method} {_shorten(url)}"
|
||||
|
||||
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:
|
||||
op_id = op_data.get('operationId', '')
|
||||
description = op_data.get('summary') or op_data.get('description', '')
|
||||
name = op_id or path.lstrip('/')
|
||||
name = op_id or f'{method} {path}'
|
||||
|
||||
url = base_url + path
|
||||
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:
|
||||
op_id = op_data.get('operationId', '')
|
||||
description = op_data.get('summary') or op_data.get('description', '')
|
||||
name = op_id or path.lstrip('/')
|
||||
name = op_id or f'{method} {path}'
|
||||
|
||||
url = base_url + path
|
||||
url = _append_required_query(url, params)
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
<property name="title" translatable="yes">Preferences</property>
|
||||
<property name="modal">True</property>
|
||||
<property name="default-width">600</property>
|
||||
<property name="default-height">400</property>
|
||||
|
||||
<child>
|
||||
<object class="AdwPreferencesPage">
|
||||
|
||||
@ -48,8 +48,6 @@ def _unique_filename(base: str, exclude: set) -> str:
|
||||
class ProjectManager:
|
||||
"""Manages project and saved request persistence."""
|
||||
|
||||
_INDEX_FILE = '_index.json'
|
||||
|
||||
def __init__(self):
|
||||
self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.bugsy.roster'
|
||||
self.projects_dir = self.data_dir / 'projects'
|
||||
@ -82,29 +80,8 @@ class ProjectManager:
|
||||
|
||||
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]:
|
||||
projects_by_id = {}
|
||||
projects = []
|
||||
self._project_dirs.clear()
|
||||
self._request_filenames.clear()
|
||||
|
||||
@ -168,24 +145,12 @@ class ProjectManager:
|
||||
environments=[Environment.from_dict(e) for e in meta.get('environments', [])],
|
||||
sensitive_variables=meta.get('sensitive_variables', []),
|
||||
)
|
||||
projects_by_id[project.id] = project
|
||||
projects.append(project)
|
||||
self._project_dirs[project.id] = proj_dir.name
|
||||
|
||||
except Exception as 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
|
||||
return projects
|
||||
|
||||
@ -334,10 +299,6 @@ class ProjectManager:
|
||||
)
|
||||
projects.append(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
|
||||
|
||||
def update_project(self, project_id: str, new_name: str = None, new_icon: str = None):
|
||||
@ -371,9 +332,6 @@ class ProjectManager:
|
||||
|
||||
if self._legacy_mode:
|
||||
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):
|
||||
if callback:
|
||||
@ -436,52 +394,6 @@ class ProjectManager:
|
||||
self._save_project(p)
|
||||
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:
|
||||
projects = self.load_projects()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@ -4,16 +4,6 @@
|
||||
<requires lib="libadwaita" version="1.0"/>
|
||||
|
||||
<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>
|
||||
<item>
|
||||
<attribute name="label">Manage Environments</attribute>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
#
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
@ -41,17 +41,11 @@ class ProjectItem(Gtk.Box):
|
||||
'request-load-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||
'request-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
|
||||
'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, other_projects=None):
|
||||
def __init__(self, project):
|
||||
super().__init__()
|
||||
self.project = project
|
||||
self.other_projects = other_projects or []
|
||||
self.expanded = False
|
||||
self._setup_actions()
|
||||
self._populate()
|
||||
@ -65,14 +59,6 @@ class ProjectItem(Gtk.Box):
|
||||
"""Setup action group for menu."""
|
||||
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.connect("activate", lambda *_: self.emit('manage-environments-requested'))
|
||||
actions.add_action(action)
|
||||
@ -96,25 +82,16 @@ class ProjectItem(Gtk.Box):
|
||||
self.name_label.set_text(self.project.name)
|
||||
self.project_icon.set_from_icon_name(self.project.icon)
|
||||
|
||||
# Clear and add requests
|
||||
while child := self.requests_listbox.get_first_child():
|
||||
self.requests_listbox.remove(child)
|
||||
|
||||
total = len(self.project.requests)
|
||||
for i, saved_request in enumerate(self.project.requests):
|
||||
for saved_request in self.project.requests:
|
||||
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',
|
||||
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',
|
||||
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))
|
||||
lambda w, sr=saved_request: self.emit('request-delete-requested', sr))
|
||||
self.requests_listbox.append(item)
|
||||
|
||||
def _on_header_clicked(self, gesture, n_press, x, y):
|
||||
@ -122,14 +99,6 @@ class ProjectItem(Gtk.Box):
|
||||
self.expanded = not self.expanded
|
||||
self.requests_revealer.set_reveal_child(self.expanded)
|
||||
|
||||
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 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
|
||||
def refresh(self):
|
||||
"""Refresh from project data."""
|
||||
self._populate()
|
||||
|
||||
@ -27,12 +27,12 @@
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkMenuButton" id="menu_button">
|
||||
<property name="icon-name">view-more-symbolic</property>
|
||||
<property name="tooltip-text">Options</property>
|
||||
<object class="GtkButton" id="delete_button">
|
||||
<property name="icon-name">edit-delete-symbolic</property>
|
||||
<property name="tooltip-text">Delete request</property>
|
||||
<signal name="clicked" handler="on_delete_clicked"/>
|
||||
<style>
|
||||
<class name="flat"/>
|
||||
<class name="request-menu-button"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
#
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
@ -18,7 +18,7 @@
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from gi.repository import Gtk, GObject, Gio, GLib
|
||||
from gi.repository import Gtk, GObject
|
||||
|
||||
_METHOD_CSS = {
|
||||
'GET': 'accent',
|
||||
@ -37,14 +37,10 @@ class RequestItem(Gtk.Box):
|
||||
|
||||
method_label = Gtk.Template.Child()
|
||||
name_label = Gtk.Template.Child()
|
||||
menu_button = Gtk.Template.Child()
|
||||
|
||||
__gsignals__ = {
|
||||
'load-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):
|
||||
@ -53,99 +49,18 @@ class RequestItem(Gtk.Box):
|
||||
method = saved_request.request.method
|
||||
self.method_label.set_text(method)
|
||||
self.method_label.add_css_class(_METHOD_CSS.get(method, 'dim-label'))
|
||||
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)
|
||||
self.name_label.set_text(saved_request.name)
|
||||
|
||||
self._setup_menu()
|
||||
|
||||
# Click gesture for loading
|
||||
# Add click gesture for loading
|
||||
gesture = Gtk.GestureClick.new()
|
||||
gesture.connect('released', self._on_clicked)
|
||||
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):
|
||||
"""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
|
||||
"""Handle click to load request."""
|
||||
self.emit('load-requested')
|
||||
|
||||
def _on_hover_enter(self, controller, x, y):
|
||||
self.menu_button.set_opacity(1.0)
|
||||
|
||||
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}")
|
||||
@Gtk.Template.Callback()
|
||||
def on_delete_clicked(self, button):
|
||||
"""Handle delete button click."""
|
||||
self.emit('delete-requested')
|
||||
|
||||
@ -952,7 +952,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
return False, f"Request name is too long (max {REQUEST_NAME_MAX_LENGTH} characters)"
|
||||
|
||||
# Check for invalid characters (file system unsafe characters)
|
||||
invalid_chars = ['\\', '*', '?', '"', '<', '>', '|', '\0']
|
||||
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0']
|
||||
for char in invalid_chars:
|
||||
if char in name:
|
||||
return False, f"Request name cannot contain '{char}'"
|
||||
@ -961,45 +961,20 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
|
||||
def _load_projects(self) -> None:
|
||||
"""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
|
||||
while child := self.projects_listbox.get_first_child():
|
||||
self.projects_listbox.remove(child)
|
||||
|
||||
# Load and populate
|
||||
projects = self.project_manager.load_projects()
|
||||
total = len(projects)
|
||||
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)
|
||||
for project in projects:
|
||||
item = ProjectItem(project)
|
||||
item.connect('edit-requested', self._on_project_edit, project)
|
||||
item.connect('delete-requested', self._on_project_delete, project)
|
||||
item.connect('add-request-requested', self._on_add_to_project, project)
|
||||
item.connect('request-load-requested', self._on_load_request)
|
||||
item.connect('request-delete-requested', self._on_delete_request, 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)
|
||||
|
||||
def on_add_project_clicked(self, button):
|
||||
@ -1137,33 +1112,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
dialog.connect('environments-updated', on_environments_updated)
|
||||
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()
|
||||
def on_save_request_clicked(self, button):
|
||||
"""Save current request to a project."""
|
||||
|
||||