Coverage for vibefpmgui/git_log_view.py: 92%
394 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The Git panel's *History* page (see `vibefpmgui.git_log`): the commits,
2newest first, each with the graph's lanes (its dot, the lines to its
3parents: a column of their own, wide enough for every lane), its branches
4and tags (badges: the branch checked out in bold), subject, author, date
5and hash; every branch's, or only what's checked out (*All branches*). The
6newest MAX_COMMITS at first; Load Older Commits lists the rest, back to the
7first. Choosing a commit shows its card (as git blame's: the
8whole message, links to its web page and issues), the files it changed, and
9the chosen file's diff (double-click a line: the file, there).
10"""
12from __future__ import annotations
14import time
15from pathlib import Path
17from qtpy.QtCore import (
18 QAbstractTableModel,
19 QModelIndex,
20 QPointF,
21 QRectF,
22 QSize,
23 Qt,
24 Signal,
25)
26from qtpy.QtGui import QBrush, QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen
27from qtpy.QtWidgets import (
28 QApplication,
29 QHBoxLayout,
30 QHeaderView,
31 QLabel,
32 QMenu,
33 QPushButton,
34 QSplitter,
35 QStackedLayout,
36 QStyle,
37 QStyledItemDelegate,
38 QTextBrowser,
39 QToolButton,
40 QTreeView,
41 QTreeWidget,
42 QTreeWidgetItem,
43 QVBoxLayout,
44 QWidget,
45)
47from . import git_blame as gb
48from . import git_log as gl
49from . import git_status as gs
50from .background import call_in_background
51from .file_diff import FileDiff
53COLUMNS = ["Graph", "Commit", "Author", "Date", "Hash"]
54GRAPH, SUBJECT = 0, 1 # (the columns drawn by GraphDelegate)
55GRAPH_SHARE = 0.6 # the most of the list's width the graph is given at first (wider: drag its edge)
56LANE_COLORS = {
57 "light": ["#4c6ef5", "#e8590c", "#2f9e44", "#ae3ec9", "#1098ad", "#e03131", "#f08c00", "#5c940d"],
58 "dark": ["#748ffc", "#ff922b", "#51cf66", "#da77f2", "#3bc9db", "#ff6b6b", "#fcc419", "#94d82d"],
59}
60REF_COLORS = {gl.HEAD: "#e03131", gl.BRANCH: "#2f9e44", gl.REMOTE: "#1c7ed6", gl.TAG: "#e8a317"}
61COMMIT_ROLE = Qt.UserRole + 1 # a row's git_log.LogCommit
62ROW_ROLE = Qt.UserRole + 2 # and its git_log.GraphRow
63CHANGE_ROLE = Qt.UserRole + 3 # a changed file's git_blame.FileChange
66def date_words(seconds: int) -> str:
67 return time.strftime("%Y-%m-%d %H:%M", time.localtime(seconds)) if seconds else ""
70class LogModel(QAbstractTableModel):
71 def __init__(self, parent=None):
72 super().__init__(parent)
73 self.log: gl.Log | None = None
74 self.lanes = 1 # the graph's width (lanes), the widest row's (every lane: none cut off)
76 def set_log(self, log: gl.Log | None):
77 self.beginResetModel()
78 self.log = log
79 self.lanes = max((r.width for r in log.rows), default=1) if log else 1
80 self.endResetModel()
82 def rowCount(self, parent=QModelIndex()): # noqa: B008 - Qt's signature
83 return 0 if parent.isValid() or self.log is None else len(self.log.commits)
85 def columnCount(self, parent=QModelIndex()): # noqa: B008 - Qt's signature
86 return 0 if parent.isValid() else len(COLUMNS)
88 def headerData(self, section, orientation, role=Qt.DisplayRole):
89 if orientation == Qt.Horizontal and role == Qt.DisplayRole:
90 return COLUMNS[section]
91 return None
93 def data(self, index, role=Qt.DisplayRole):
94 if not index.isValid() or self.log is None: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true
95 return None
96 commit = self.log.commits[index.row()]
97 column = index.column()
98 if role == Qt.DisplayRole:
99 return ["", commit.subject, commit.author, date_words(commit.time), commit.sha[:8]][column]
100 if role == Qt.ToolTipRole: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 refs = ", ".join(r.name for r in commit.refs)
102 subject = f"{commit.subject}" + (f"\n{refs}" if refs else "")
103 return [subject, subject, f"{commit.author} <{commit.email}>", None, commit.sha][column]
104 if role == COMMIT_ROLE:
105 return commit
106 if role == ROW_ROLE:
107 return self.log.rows[index.row()]
108 return None
111def lane_width(font: QFont) -> float:
112 """A lane's width in the graph, for the list's font."""
113 return max(12.0, QFontMetrics(font).height() * 0.8)
116def graph_width(lanes: int, font: QFont) -> int:
117 """The Graph column's width for every one of ``lanes``."""
118 return round(8 + lane_width(font) * lanes)
121class GraphDelegate(QStyledItemDelegate):
122 """The Graph column (the lanes, the commit's dot) and the Commit column
123 (its branch and tag badges, its subject)."""
125 def __init__(self, view: GitLogView):
126 super().__init__(view.table)
127 self.view = view
129 def sizeHint(self, option, index):
130 size = super().sizeHint(option, index)
131 return QSize(size.width(), max(size.height(), round(QFontMetrics(option.font).height() * 1.5)))
133 def paint(self, painter, option, index):
134 commit: gl.LogCommit = index.data(COMMIT_ROLE)
135 row: gl.GraphRow = index.data(ROW_ROLE)
136 style = option.widget.style() if option.widget else QApplication.style()
137 style.drawPrimitive(QStyle.PE_PanelItemViewItem, option, painter, option.widget)
138 painter.save()
139 painter.setRenderHint(QPainter.Antialiasing)
140 rect = QRectF(option.rect)
141 if index.column() == SUBJECT:
142 self._paint_subject(painter, option, commit, rect)
143 painter.restore()
144 return
145 lane = lane_width(option.font)
146 colors = LANE_COLORS["dark" if self.view.dark else "light"]
147 top, bottom, middle = rect.top(), rect.bottom() + 1, rect.center().y()
149 def x(i: int) -> float:
150 return rect.left() + 4 + lane * (i + 0.5)
152 def pen(color: int) -> QPen:
153 line = QPen(QColor(colors[color % len(colors)]), 2)
154 line.setCapStyle(Qt.RoundCap)
155 return line
157 painter.setClipRect(rect) # (the column made narrower than the graph: the rest cut off)
158 for i, color in row.through:
159 painter.setPen(pen(color))
160 painter.drawLine(QPointF(x(i), top), QPointF(x(i), bottom))
161 for i, color in row.joining: # (from its lane above, curving into the commit)
162 painter.setPen(pen(color))
163 path = QPainterPath(QPointF(x(i), top))
164 path.cubicTo(QPointF(x(i), middle), QPointF(x(i), middle), QPointF(x(row.lane), middle))
165 painter.drawPath(path)
166 if row.from_above:
167 painter.setPen(pen(row.color))
168 painter.drawLine(QPointF(x(row.lane), top), QPointF(x(row.lane), middle))
169 for i, color in row.parents:
170 painter.setPen(pen(color))
171 if i == row.lane:
172 painter.drawLine(QPointF(x(i), middle), QPointF(x(i), bottom))
173 else: # (a merge's other parent, or a lane waiting for it: curving out to it)
174 path = QPainterPath(QPointF(x(row.lane), middle))
175 path.cubicTo(QPointF(x(i), middle), QPointF(x(i), middle), QPointF(x(i), bottom))
176 painter.drawPath(path)
177 radius = lane * 0.28
178 dot = QColor(colors[row.color % len(colors)])
179 center = QPointF(x(row.lane), middle)
180 is_head = self.view.model.log is not None and commit.sha == self.view.model.log.head
181 if len(commit.parents) > 1: # (a merge: a ring)
182 painter.setPen(QPen(dot, 2))
183 painter.setBrush(QBrush(QColor(self.view.pal.get("bg", "#ffffff"))))
184 else:
185 painter.setPen(Qt.NoPen)
186 painter.setBrush(QBrush(dot))
187 painter.drawEllipse(center, radius, radius)
188 if is_head: # (what's checked out: a ring around it)
189 painter.setPen(QPen(QColor(self.view.pal.get("text", "#000000")), 1.5))
190 painter.setBrush(Qt.NoBrush)
191 painter.drawEllipse(center, radius + 3, radius + 3)
192 painter.restore()
194 def _paint_subject(self, painter, option, commit: gl.LogCommit, rect: QRectF):
195 """The commit's branches and tags (badges), then its subject."""
196 selected = bool(option.state & QStyle.State_Selected)
197 middle = rect.center().y()
198 left = rect.left() + 4
199 small = QFont(option.font)
200 small.setPointSizeF(max(7.0, option.font.pointSizeF() - 1.5))
201 badge_metrics = QFontMetrics(small)
202 for ref in commit.refs:
203 if left > rect.right() - 40: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 break
205 bold = QFont(small)
206 bold.setBold(ref.current)
207 text_width = QFontMetrics(bold).horizontalAdvance(ref.name)
208 badge = QRectF(left, middle - badge_metrics.height() / 2 - 1, text_width + 10, badge_metrics.height() + 2)
209 color = QColor(REF_COLORS[ref.kind])
210 solid = ref.current or selected # (on the selection's color: solid, so it shows)
211 painter.setPen(QPen(QColor("white") if selected else color, 1.2 if ref.current else 1))
212 fill = QColor(color)
213 fill.setAlphaF(0.9 if solid else 0.18)
214 painter.setBrush(fill)
215 painter.drawRoundedRect(badge, 4, 4)
216 painter.setFont(bold)
217 painter.setPen(QColor("white") if solid else color if not self.view.dark else color.lighter(130))
218 painter.drawText(badge, Qt.AlignCenter, ref.name)
219 left = badge.right() + 4
220 painter.setFont(option.font)
221 painter.setPen(option.palette.color(option.palette.ColorRole.HighlightedText if selected
222 else option.palette.ColorRole.Text))
223 text_rect = QRectF(left + 2, rect.top(), rect.right() - left - 4, rect.height())
224 painter.drawText(text_rect, Qt.AlignVCenter | Qt.AlignLeft,
225 QFontMetrics(option.font).elidedText(commit.subject, Qt.ElideRight, int(text_rect.width())))
228class GitLogView(QWidget):
229 open_requested = Signal(str, int) # a file of the working tree, a line
230 reveal_requested = Signal(str) # a changed file's "Show in Project Files": its path now
231 all_branches_changed = Signal(bool) # All branches toggled
232 reload_requested = Signal() # (All branches toggled, older commits asked for: see reload_key)
234 def __init__(self, parent=None):
235 super().__init__(parent)
236 self.log: gl.Log | None = None
237 self.pal: dict = {}
238 self.dark = False
239 self.icon_for = None # a file's QIcon (set by the panel)
240 self.everything: set[Path] = set() # repositories whose every commit is listed (Load Older Commits)
241 self._graph_lanes = 0 # (the lanes the Graph column was last made wide enough for)
242 self._details: tuple[str, object] | None = None # (a commit, its details: CommitDetails, None
243 # while they're read, or why git couldn't), the one chosen
245 self.all_branches = QToolButton()
246 self.all_branches.setObjectName("GitView")
247 self.all_branches.setText("All branches")
248 self.all_branches.setToolTip("Every branch's commits (local and remote) and tags', or only those of what's "
249 "checked out")
250 self.all_branches.setCheckable(True)
251 self.all_branches.setChecked(True)
252 self.all_branches.setAutoRaise(True)
253 self.all_branches.clicked.connect(self._all_branches_clicked)
254 self.count = QLabel()
255 self.older_button = QPushButton("Load Older Commits")
256 self.older_button.setToolTip("List every commit, back to the first one (at first, only the newest "
257 f"{gl.MAX_COMMITS:,} are)")
258 self.older_button.clicked.connect(self.load_older)
259 self.older_button.hide()
260 bar = QHBoxLayout()
261 bar.setContentsMargins(0, 0, 0, 0)
262 bar.addWidget(self.count)
263 bar.addWidget(self.older_button)
264 bar.addStretch(1)
265 bar.addWidget(self.all_branches)
267 self.model = LogModel(self)
268 self.table = QTreeView()
269 self.table.setModel(self.model)
270 self.table.setRootIsDecorated(False)
271 self.table.setUniformRowHeights(True)
272 self.table.setAllColumnsShowFocus(True)
273 delegate = GraphDelegate(self)
274 self.table.setItemDelegateForColumn(GRAPH, delegate)
275 self.table.setItemDelegateForColumn(SUBJECT, delegate)
276 self.table.header().setStretchLastSection(False)
277 self.table.header().setSectionResizeMode(SUBJECT, QHeaderView.Stretch)
278 for column in (GRAPH, 2, 3, 4):
279 self.table.header().setSectionResizeMode(column, QHeaderView.Interactive)
280 self.table.selectionModel().currentRowChanged.connect(self._commit_chosen)
282 self.card = QTextBrowser()
283 self.card.setOpenLinks(False)
284 self.card.anchorClicked.connect(self._link)
285 self.files = QTreeWidget()
286 self.files.setHeaderHidden(True)
287 self.files.setRootIsDecorated(False)
288 self.files.setColumnCount(3) # (the file, its lines added and deleted, its letter: M, A, D…)
289 self.files.setUniformRowHeights(True)
290 self.files.currentItemChanged.connect(self._file_chosen)
291 self.files.setContextMenuPolicy(Qt.CustomContextMenu)
292 self.files.customContextMenuRequested.connect(self._file_menu)
293 self.file_menu: QMenu | None = None # (kept while it's open)
294 self.file_diff = FileDiff()
295 self.file_diff.open_requested.connect(self._open_line)
296 details = QSplitter(Qt.Vertical)
297 details.addWidget(self.card)
298 details.addWidget(self.files)
299 details.setStretchFactor(0, 3)
300 details.setStretchFactor(1, 2)
301 self.bottom = QSplitter(Qt.Horizontal)
302 self.bottom.addWidget(details)
303 self.bottom.addWidget(self.file_diff)
304 self.bottom.setStretchFactor(0, 2)
305 self.bottom.setStretchFactor(1, 3)
306 self.splitter = QSplitter(Qt.Vertical)
307 self.splitter.addWidget(self.table)
308 self.splitter.addWidget(self.bottom)
309 self.splitter.setStretchFactor(0, 3)
310 self.splitter.setStretchFactor(1, 2)
312 content = QWidget()
313 layout = QVBoxLayout(content)
314 layout.setContentsMargins(0, 0, 0, 0)
315 layout.setSpacing(4)
316 layout.addLayout(bar)
317 layout.addWidget(self.splitter, 1)
318 self.message = QLabel()
319 self.message.setAlignment(Qt.AlignCenter)
320 self.message.setWordWrap(True)
321 self.stack = QStackedLayout(self)
322 self.stack.addWidget(self.message)
323 self.stack.addWidget(content)
324 self.content = content
325 self.show_message("Reading the repository…")
327 # ------------------------------------------------------------ styling
328 def refresh_palette(self, pal: dict, dark: bool):
329 """New colors: drawn again from what's been read (git isn't asked again;
330 the commit and file chosen, and the diff's place, stay)."""
331 self.pal, self.dark = pal, dark
332 for label in (self.count, self.message):
333 label.setStyleSheet(f"color: {pal['muted']};")
334 self.file_diff.refresh_style(pal["muted"])
335 self.table.viewport().update()
336 commit = self.current_commit()
337 if commit is not None and self._details is not None and self._details[0] == commit.sha:
338 self._show_card(commit, self._details[1])
339 muted = QBrush(QColor(pal["muted"]))
340 for i in range(self.files.topLevelItemCount()):
341 self.files.topLevelItem(i).setForeground(1, muted)
343 def set_code_font(self, font: QFont):
344 self.file_diff.set_font(font)
346 # ------------------------------------------------------- read by the panel
347 # (The Git panel's pages that read from the repository: see vibefpmgui.git_panel.)
348 def reload_key(self, root: Path) -> tuple:
349 """What, besides the repository and HEAD, the history read depends on:
350 All branches, and whether every commit is listed (Load Older Commits)."""
351 return self.all_branches.isChecked(), root in self.everything
353 def reader(self, root: Path):
354 """What reads it (on a pool thread)."""
355 all_branches = self.all_branches.isChecked()
356 limit = None if root in self.everything else gl.MAX_COMMITS
357 return lambda: gl.read_log(root, all_branches=all_branches, limit=limit)
359 def load_older(self):
360 """Load Older Commits: every commit listed, back to the first (this
361 repository's, from now on while the app runs)."""
362 if self.log is None: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true
363 return
364 self.everything.add(self.log.root)
365 self.older_button.setEnabled(False)
366 self.older_button.setText("Reading every commit…")
367 self.reload_requested.emit()
369 def show_result(self, log: gl.Log):
370 self.show_log(log)
372 # -------------------------------------------------------------- state
373 def show_message(self, text: str):
374 self.log = None
375 self.model.set_log(None)
376 self.older_button.hide()
377 self.message.setText(text)
378 self.stack.setCurrentWidget(self.message)
380 def show_log(self, log: gl.Log):
381 """A repository's history; the commit chosen before stays chosen if it's still listed."""
382 before = self.current_commit()
383 self.log = log
384 self.model.set_log(log)
385 self.all_branches.setChecked(log.all_branches)
386 n = len(log.commits)
387 self.count.setText(f"{'The newest ' if log.limited else ''}{n:,} commit{'s' * (n != 1)}"
388 + (f" of {log.total:,}" if log.limited else "")
389 + (" of every branch" if log.all_branches else " of what's checked out"))
390 older = log.total - n
391 self.older_button.setVisible(log.limited)
392 self.older_button.setEnabled(True)
393 self.older_button.setText(f"Load the {older:,} Older Commit{'s' * (older != 1)}")
394 metrics = QFontMetrics(self.table.font())
395 self.table.setColumnWidth(2, 160)
396 self.table.setColumnWidth(3, metrics.horizontalAdvance("0000-00-00 00:00") + 16)
397 self.table.setColumnWidth(4, metrics.horizontalAdvance("00000000") + 16)
398 if self.model.lanes != self._graph_lanes: # (more lanes, or fewer: room for each; dragged since: kept)
399 self._graph_lanes = self.model.lanes
400 width = graph_width(self.model.lanes, self.table.font())
401 room = self.table.viewport().width()
402 self.table.setColumnWidth(GRAPH, min(width, round(room * GRAPH_SHARE)) if room > 200 else width)
403 self.stack.setCurrentWidget(self.content)
404 row = next((i for i, c in enumerate(log.commits) if before and c.sha == before.sha), None)
405 if row is None:
406 row = next((i for i, c in enumerate(log.commits) if c.sha == log.head), 0)
407 self.table.setCurrentIndex(self.model.index(row, 0))
408 self.table.scrollTo(self.model.index(row, 0), QTreeView.PositionAtCenter)
410 def current_commit(self) -> gl.LogCommit | None:
411 index = self.table.currentIndex()
412 return index.data(COMMIT_ROLE) if index.isValid() else None
414 def _all_branches_clicked(self, on: bool):
415 self.all_branches_changed.emit(on)
416 self.reload_requested.emit()
418 # ------------------------------------------------------------ a commit
419 def _commit_chosen(self, index, _previous=None):
420 commit = index.data(COMMIT_ROLE) if index.isValid() else None
421 self.files.clear()
422 self.file_diff.clear()
423 if commit is None or self.log is None: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true
424 self._details = None
425 self.card.clear()
426 return
427 self._details = (commit.sha, None)
428 self._show_card(commit, None)
429 root = self.log.root
431 def read():
432 try:
433 return gb.commit_details(root, commit.sha)
434 except (gs.GitError, OSError) as error:
435 return str(error) or type(error).__name__
437 call_in_background(read, lambda result: self._details_read(commit, result), self)
439 def _show_card(self, commit: gl.LogCommit, details):
440 shown = details
441 if isinstance(details, gb.CommitDetails): # (its files: in the list under it)
442 shown = gb.CommitDetails(details.email, details.message, details.committer, details.commit_time, None)
443 text = QColor(self.pal.get("text", "#000000"))
444 html = gb.commit_card_html(gb.Commit(commit.sha, commit.author, commit.time, commit.subject), shown,
445 self.log.remote if self.log else "", text)
446 self.card.setHtml(html)
448 def _details_read(self, commit: gl.LogCommit, result):
449 if self._details is None or self._details[0] != commit.sha: 449 ↛ 450line 449 didn't jump to line 450 because the condition on line 449 was never true
450 return # (another commit chosen meanwhile)
451 self._details = (commit.sha, result)
452 self._show_card(commit, result)
453 if not isinstance(result, gb.CommitDetails): 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true
454 return
455 for change in result.files or []:
456 name = f"{change.old_path} → {change.path}" if change.old_path else change.path
457 counts = "binary" if change.added is None else f"+{change.added} −{change.deleted}"
458 item = QTreeWidgetItem([name, counts, change.status])
459 item.setData(0, CHANGE_ROLE, change)
460 word, color = gb.STATUS_CHIPS.get(change.status, (change.status, "#868e96"))
461 item.setToolTip(0, f"{change.path}\n{word}")
462 item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter)
463 item.setForeground(1, QBrush(QColor(self.pal.get("muted", "#888888"))))
464 item.setForeground(2, QBrush(QColor(color)))
465 item.setToolTip(2, word)
466 if self.icon_for is not None and self.log is not None: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true
467 item.setIcon(0, self.icon_for(self.log.root / change.path))
468 self.files.addTopLevelItem(item)
469 header = self.files.header()
470 header.setStretchLastSection(False)
471 header.setSectionResizeMode(0, QHeaderView.Stretch)
472 header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
473 header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
474 if self.files.topLevelItemCount(): 474 ↛ exitline 474 didn't return from function '_details_read' because the condition on line 474 was always true
475 self.files.setCurrentItem(self.files.topLevelItem(0))
477 # -------------------------------------------------------------- a file
478 def _file_chosen(self, item, _previous=None):
479 change = item.data(0, CHANGE_ROLE) if item is not None else None
480 commit = self.current_commit()
481 if change is None or commit is None or self.log is None:
482 return
483 root = self.log.root
485 def read():
486 before, after = gl.file_versions(root, commit, change)
487 parent = commit.parents[0][:8] if commit.parents else ""
488 return before, after, f"{parent} → {commit.sha[:8]}" if parent else f"added in {commit.sha[:8]}"
490 self.file_diff.load((commit.sha, change.path), change.path, read)
492 def _file_menu(self, pos):
493 """A changed file's menu (a right-click on it): Show in Project Files,
494 if it's still there (where the commit left it) in the working tree."""
495 item = self.files.itemAt(pos)
496 change = item.data(0, CHANGE_ROLE) if item is not None else None
497 if change is None or self.log is None: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true
498 return
499 path = self.log.root / change.path
500 menu = self.file_menu = QMenu(self)
501 show = menu.addAction("Show in Project Files", lambda: self.reveal_requested.emit(str(path)))
502 if change.status == "D" or not path.exists():
503 show.setEnabled(False)
504 show.setText("Show in Project Files (not there now)")
505 menu.aboutToHide.connect(menu.deleteLater)
506 menu.popup(self.files.viewport().mapToGlobal(pos))
508 def _open_line(self, line: int):
509 item = self.files.currentItem()
510 change = item.data(0, CHANGE_ROLE) if item is not None else None
511 if change is not None and self.log is not None and (self.log.root / change.path).is_file(): 511 ↛ exitline 511 didn't return from function '_open_line' because the condition on line 511 was always true
512 self.open_requested.emit(str(self.log.root / change.path), line)
514 def _link(self, url):
515 target = url.toString()
516 if target.startswith("copy:"):
517 QApplication.clipboard().setText(target.removeprefix("copy:"))
518 elif target.startswith(("http:", "https:")):
519 gb.open_web_page(target)