Skip to content
Open
2 changes: 2 additions & 0 deletions data/bookmark-filled-symbolic.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions data/bookmark-outline-symbolic.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions data/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ install_data('dev.tchx84.Portfolio-symbolic.svg',
install_dir: join_paths(get_option('datadir'), 'icons/hicolor/symbolic/apps')
)

install_data('bookmark-filled-symbolic.svg',
install_dir: join_paths(get_option('datadir'), 'icons/hicolor/scalable/apps')
)

install_data('bookmark-outline-symbolic.svg',
install_dir: join_paths(get_option('datadir'), 'icons/hicolor/symbolic/apps')
)

appstreamcli = find_program('appstreamcli', required: false)
if appstreamcli.found()
test('Validate appstream file', appstreamcli,
Expand Down
109 changes: 109 additions & 0 deletions src/bookmarks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# devices.py
#
# Copyright 2025 Jason Beetham
#
# This program is free software: you can redistribute it and/or modify
# 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
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from gi.repository import GLib, GObject, Gtk

import json, os


class PortfolioBookmarks(GObject.GObject):
__gtype_name__ = "PortfolioBookmarks"

__gsignals__ = {
"add-bookmark": (GObject.SignalFlags.RUN_LAST, None, (str,)),
"remove-bookmark": (GObject.SignalFlags.RUN_LAST, None, (str,)),
}

def __init__(self):
GObject.GObject.__init__(self)

self.bookmarked = set()
self._portfolio_config_path = os.path.join(
GLib.get_user_config_dir(), "portfolio"
)
self._bookmark_path = os.path.join(self._portfolio_config_path, "bookmarks")

try:
with open(self._bookmark_path, "r") as f:
data = f.read()
if not data is None:
bookmarked = json.loads(data)
for val in bookmarked:
if type(val) is not str:
bookmarked = []
break

for path in bookmarked:
self._add_bookmark(path)

except (json.JSONDecodeError, OSError, IOError):
self.bookmarked = set()

def _add_bookmark(self, path):
if path not in self.bookmarked:
self.bookmarked.add(path)
self.emit("add-bookmark", path)

def _delete_bookmark(self, path):
if path in self.bookmarked:
self.bookmarked.remove(path)
self.emit("remove-bookmark", path)

def is_bookmarked(self, path):
return path in self.bookmarked

def _save_bookmarks(self):
os.makedirs(self._portfolio_config_path, exist_ok=True)
with open(self._bookmark_path, "w") as f:
paths = [bookmark for bookmark in self.bookmarked]
json.dump(paths, f)

def toggle_bookmark(self, button, path):
if self.is_bookmarked(path):
self._delete_bookmark(path)
else:
self._add_bookmark(path)
self._save_bookmarks()


class PortfolioBookmarkButton(Gtk.Button):
__gtype_name__ = "PortfolioBookmarkButton"

def __init__(self, bookmarks):
Gtk.Button.__init__(self)
self.connect("clicked", self._on_bookmark_toggle)
self._bookmarks = bookmarks
self.props.icon_name = "bookmark-filled-symbolic"

def _change_icon(self):
if self._bookmarks.is_bookmarked(self._path):
self.props.icon_name = "bookmark-filled-symbolic"
else:
self.props.icon_name = "bookmark-outline-symbolic"

def _on_bookmark_toggle(self, button):
self._bookmarks.toggle_bookmark(button, self._path)
self._change_icon()

@property
def path(self):
self._path

@path.setter
def path(self, path):
self._path = path
self._change_icon()
1 change: 1 addition & 0 deletions src/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ portfolio_sources = [
'loading.py',
'files.py',
'menu.py',
'bookmarks.py'
]

install_data(portfolio_sources, install_dir: moduledir)
1 change: 1 addition & 0 deletions src/place.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class PortfolioPlace(Adw.ActionRow):

icon = Gtk.Template.Child()
eject = Gtk.Template.Child()
remove_bookmark = Gtk.Template.Child()
mount = None
path = ""
uuid = ""
Expand Down
14 changes: 14 additions & 0 deletions src/place.ui
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@
<class name="eject-button"/>
</style>
</object>
<object class="GtkButton" id="remove_bookmark">
<property name="visible">0</property>
<property name="hexpand">1</property>
<property name="halign">end</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="icon-name">bookmark-outline-symbolic</property>
</object>
</child>
<style>
<class name="remove-bookmark"/>
</style>
</object>
</child>
</object>
</child>
Expand Down
61 changes: 57 additions & 4 deletions src/places.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from . import logger
from .place import PortfolioPlace
from .devices import PortfolioDevices
from .bookmarks import PortfolioBookmarks
from .translation import gettext as _


Expand Down Expand Up @@ -60,21 +61,23 @@ class PortfolioPlaces(Gtk.Stack):
VIDEOS_PERMISSION = ["host", "home", "xdg-videos"]
TRASH_PERMISSION = ["host", "home"]

def __init__(self, **kargs):
def __init__(self, bookmarks, **kargs):
super().__init__(**kargs)
self._setup()
self._setup(bookmarks)

def _setup(self):
def _setup(self, bookmarks):
self.props.visible = True
self.props.transition_type = Gtk.StackTransitionType.CROSSFADE

self._bookmarks = bookmarks
self._permissions = None

self._devices = PortfolioDevices()
self._devices.connect("added", self._on_device_added)
self._devices.connect("removed", self._on_device_removed)
self._devices.connect("encrypted-added", self._on_encrypted_added)

self._bookmarks = PortfolioBookmarks()

# begin UI structure

self._groups_box = Gtk.Box()
Expand All @@ -94,6 +97,10 @@ def _setup(self):
self._devices_group.props.visible = True
self._devices_group.get_style_context().add_class("devices-group")

self._bookmarks_group = Adw.PreferencesGroup()
self._bookmarks_group.props.title = _("Bookmarks")
self._bookmarks_group.props.visible = True

# places

if self._has_permission_for(self.HOME_PERMISSION):
Expand Down Expand Up @@ -166,10 +173,15 @@ def _setup(self):

self._groups_box.append(self._places_group)
self._groups_box.append(self._devices_group)
self._groups_box.append(self._bookmarks_group)

self._places_listbox = utils.find_child_by_id(self._places_group, "listbox")
self._devices_listbox = utils.find_child_by_id(self._devices_group, "listbox")
self._bookmarks_listbox = utils.find_child_by_id(
self._bookmarks_group, "listbox"
)

self._add_accessible_bookmarks()
# no places message

message = Gtk.Label()
Expand All @@ -192,6 +204,9 @@ def _setup(self):

self._update_visibility()

bookmarks.connect("add-bookmark", self._on_bookmark_added)
bookmarks.connect("remove-bookmark", self._on_bookmark_removed)

def _update_visibility(self):
self._update_stack_visibility()
self._update_places_group_visibility()
Expand All @@ -214,6 +229,10 @@ def _update_device_group_visibility(self):
visible = len(list(self._devices_listbox)) >= 1
self._devices_group.props.visible = visible

def _update_bookmarks_group_visibility(self):
visible = len(list(self._bookmarks_listbox)) >= 1
self._bookmarks_group.props.visible = visible

def _get_permissions(self):
if self._permissions is not None:
return self._permissions
Expand Down Expand Up @@ -384,3 +403,37 @@ def _on_encrypted_eject_finished(self, encrypted, success):
self._on_device_removed(None, encrypted)
else:
self.emit("failed", None)

def _find_place_by_path(self, listbox, path):
for place in listbox:
if place.path == path:
return place
return None

def _on_bookmark_removed(self, bookmark, path):
place = self._find_place_by_path(self._bookmarks_listbox, path)
if place is not None:
self._bookmarks_group.remove(place)

def _on_bookmark_added(self, bookmark, path):
if self._find_place_by_path(self._bookmarks_listbox, path) is None:
self._add_bookmark_place(path)

def _add_accessible_bookmarks(self):
for path in self._bookmarks.bookmarked:
not_added = self._find_place_by_path(self._bookmarks_listbox, path) is None
if not_added and os.path.isdir(
path
): # Might be a bookmarked path on a unmounted device
self._add_bookmark_place(path)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need to check if it's in widget? If you're worried about duplicates, that's something you can deal with in the mode (e.g., using a set instead of a list). Same thing for available or unavailable bookmarks.

So this ideally look like:

for path in self._bookmarks.bookmarked:
    self._add_bookmark_place(path)

Copy link
Author

@beef331 beef331 Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One may connect a device which has paths stored in their bookmarks, the check was to not repeat add to the collection. As on start up it can be come disjointed from the stored list as there may not be bookmarks accessible at the time of opening.


def _add_bookmark_place(self, path):
name = os.path.basename(path)
place = self._add_place(
self._bookmarks_group,
"bookmark-filled-symbolic",
name,
path,
)
place.remove_bookmark.props.visible = True
place.remove_bookmark.connect("clicked", self._on_bookmark_removed, path)
10 changes: 9 additions & 1 deletion src/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from .files import PortfolioFiles
from .menu import PortfolioMenu
from .settings import PortfolioSettings
from .bookmarks import PortfolioBookmarks, PortfolioBookmarkButton
from .trash import default_trash


Expand All @@ -61,6 +62,7 @@ class PortfolioWindow(Adw.ApplicationWindow):
paste = Gtk.Template.Child()
select_all = Gtk.Template.Child()
select_none = Gtk.Template.Child()
bookmark_box = Gtk.Template.Child()
new_folder = Gtk.Template.Child()
delete_trash = Gtk.Template.Child()
restore_trash = Gtk.Template.Child()
Expand Down Expand Up @@ -148,6 +150,7 @@ def _setup(self):
self.select_all.connect("clicked", self._on_select_all)
self.select_none.connect("clicked", self._on_select_none)
self.new_folder.connect("clicked", self._on_new_folder)

self.close_button.connect("clicked", self._on_button_closed)
self.go_top_button.connect("clicked", self._go_to_top)
self.stop_button.connect("clicked", self._on_stop_clicked)
Expand Down Expand Up @@ -181,8 +184,12 @@ def _setup(self):
self.files.connect("adjustment-changed", self._on_files_adjustment_changed)
self.files.sort_order = self._settings.sort_order
self.content_inner_box.append(self.files)
self._bookmarks = PortfolioBookmarks()

self._bookmark_button = PortfolioBookmarkButton(self._bookmarks)
self.bookmark_box.append(self._bookmark_button)

places = PortfolioPlaces()
places = PortfolioPlaces(self._bookmarks)
places.connect("updated", self._on_places_updated)
places.connect("removing", self._on_places_removing)
places.connect("removed", self._on_places_removed)
Expand Down Expand Up @@ -224,6 +231,7 @@ def _populate(self, directory):
self._worker.connect("finished", self._on_load_finished)
self._worker.connect("failed", self._on_load_failed)
self._worker.start()
self._bookmark_button.path = directory

def _paste(self, Worker, to_paste):
directory = self._history[self._index]
Expand Down
7 changes: 7 additions & 0 deletions src/window.ui
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,13 @@
<property name="child">
<object class="GtkBox" id="navigation_tools">
<property name="halign">end</property>
<child>
<object class="GtkBox" id="bookmark_box">
<child>
<placeholder/>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="paste">
<property name="sensitive">0</property>
Expand Down