Compare commits

..

No commits in common. "master" and "feature/per-file-storage" have entirely different histories.

18 changed files with 33 additions and 332 deletions

View File

@ -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:

View File

@ -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>

View File

@ -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', ],
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -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">

View File

@ -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."""

View File

@ -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)

View File

@ -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)

View File

@ -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">

View File

@ -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()

View File

@ -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>

View File

@ -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()

View File

@ -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>

View File

@ -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')

View File

@ -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."""