Compare commits

...
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 896ef4cb2e fix(vegas): stop the width cap emitting fragments and stale windows
Two defects in the rotation that narrows an oversized plugin to its
width budget. Both were found while investigating "cut off early /
starts in the middle" reports and are the reason the cap is no longer
on by default; they still bite anyone who sets one.

A rotation's last window was whatever happened to be left over. Windows
are placed by walking forward from the previous one, with nothing
looking at the remainder, so a 1,840px stocks ticker against a 1,536px
budget split 1,492 + 348 -- every other appearance showed seven seconds
and cut. Absorb a remainder below half a budget into the window before
it. That overruns the budget by at most half, which is the better trade:
the budget guards against one plugin holding the panel for minutes, not
against a 20% overshoot. The floor is measured against the budget rather
than the panel because snapping to item boundaries already lands an
ordinary window short of it -- a 512px budget over 182px-pitch items
yields 348px windows, so an absolute floor merges windows that were
never fragments.

The stored offset also outlived the content it was recorded against. It
was a pixel column, reused verbatim after the plugin re-rendered, so
once anything ahead of it changed width the window pointed at unrelated
items -- observed as news refreshing 9,793px -> 9,505px mid-rotation.
Track the rotation as an index into the strip's item boundaries instead,
since the Nth boundary survives a digit appearing in a price, and record
alongside it what the offset indexes into: a row list, a boundary list,
or a column in a gapless image. A mismatch restarts the rotation rather
than reinterpreting the number, which also closes the case where one
plugin's row index was read back as a pixel column after its content
changed from several rows to one wide strip.

Replaying the four plugins that actually hit the cap on a live 512px
panel: no window is now a fragment, none exceeds 1.5 budgets, and every
rotation still covers the whole strip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-07 16:27:56 -04:00
ChuckandGitHub fc25a70d75 fix(web): make the update button work on branches without tracking (#443)
Reported from a pi whose checkout sat on a local branch:

    git pull failed (returncode=1): There is no tracking information for
    the current branch. Please specify which branch you want to rebase
    against.

The Tools tab reported that as "Update failed; check logs for details",
which tells the user nothing they can act on, and the underlying git
message never reached the UI at all.

A branch with no upstream is easy to end up on — checking one out by
name, restoring a backup, or following a guide that names a branch — and
until now it left the update button permanently broken with no way out
except SSH.

resolve_pull_command() now decides how to pull:
  - upstream set                  -> git pull --rebase, as before
  - no upstream, origin/<branch>  -> git pull --rebase origin <branch>,
    then attach tracking so the next update is a plain pull
  - no upstream, no remote branch -> an error naming the branch and
    pointing at Switch branch
  - detached HEAD                 -> says so, rather than failing obscurely

That resolution happens BEFORE the stash. Previously the handler stashed
local changes and then discovered it could not pull, putting the user's
work away for an update that was never going to run.

Failures now surface git's own message instead of "check logs".

Adds a branch picker to the Tools tab, backed by GET
/system/git-branches (local + remote-only) and a checkout_branch action.
Switching attaches tracking, so Pull Latest works afterwards. Branch
names are validated against a strict pattern before reaching a subprocess
argument list.

Local edits block a checkout, as they should. Rather than a truncated
one-line error, the response carries git's full list of blocking files
and a can_retry_with_stash flag; the UI then offers "Stash and switch" as
an explicit choice. Stashing is never done unasked — putting someone's
edits away without consent is worse than refusing the switch.

Verified on the pi that produced the report: on its untracked 'audit'
branch the update now returns the actionable message, git-info reports
upstream='' and can_pull=false, and an injected branch name is rejected.
27 tests build real git repositories and cover each path, including the
stash route that could not be exercised safely on the device.
2026-08-07 13:27:43 -04:00
003312f4ff feat(web): let schemas label enum dropdown options (#442)
* feat(web): let schemas label enum dropdown options

An enum property renders as a dropdown whose option text is derived from
the value — underscores replaced, title case applied. That works when the
value reads as its own label and fails when it does not: "vs" renders as
"Vs", and "abbrev" tells the user nothing about the "Sep 19" it produces.
Schemas had no way to say otherwise, so the label was whatever the config
key happened to look like.

Enum dropdowns now take their option text from x-options.labels when the
schema supplies it. This is not a new convention: the checkbox-group
widget has read x-options.labels since it was written, with the same
humanised fallback. This extends it to plain enums and to array-table
columns.

Display only — the option value, and so the saved config, is unchanged.
The map may be partial; unlabelled values keep the humanised fallback, so
every existing schema renders exactly as before. Older cores ignore
x-options entirely, which means a plugin can ship labels without
requiring users to upgrade first.

Array-table columns get the same lookup but keep the raw value as their
fallback rather than the humanised one. Those columns hold values such as
ticker symbols, where "aapl" -> "Aapl" would be wrong, and they were not
being humanised before this change.

Verified against the running web service: with labels the hockey plugin's
date dropdown reads "Sep 19 / 9/19 / 19 Sep / 19/9 / Fri Sep 19"; with
the pre-change template and the same schema it falls back to
"Abbrev / Numeric / Day First / ...", confirming the degradation path.

* fix(web): label enum options in dynamically added table rows, and test the
shipped template

Both points from the CodeRabbit review on #442.

array-table.js built enum <option> elements with o.textContent = opt, so a
row added with "Add row" showed the raw value while the server-rendered
rows above it showed the schema's label — the same column reading two
different ways until the page was reloaded. Both option-building sites now
go through a shared enumOptionLabel(), which mirrors the template exactly:
x-options or x_options, labels map, raw value as the fallback.

The tests rendered a copy of the template expression, so they could pass
while production drifted. They now extract the live enum <select> block
out of plugin_config.html and render that, and assert on the full
value -> label map rather than substring presence.

Mutation-checked, since a guard that cannot fail is not a guard:
  - remove the labels lookup      -> 5 of 9 fail
  - change only the fallback to   -> 2 of 9 fail
    option|upper (keeping the
    enum_labels.get call intact)
  - revert the JS to raw values   -> 1 of 9 fails
The middle case is the one the review called out as able to slip through.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 13:27:20 -04:00
9 changed files with 1070 additions and 29 deletions
+41
View File
@@ -206,6 +206,47 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the
The widget will be automatically rendered when the plugin configuration form is loaded. The widget will be automatically rendered when the plugin configuration form is loaded.
## Labelling Enum Options (`x-options.labels`)
A plain `enum` renders as a dropdown whose option text is the value with
underscores replaced and title case applied — `day_first` becomes "Day First".
That is fine for values that read as their own label, and wrong for values that
do not: `vs` becomes "Vs", and `abbrev` says nothing about the `Sep 19` it
actually produces.
Supply `x-options.labels` to set the visible text. This is the same convention
the `checkbox-group` widget uses:
```json
{
"date_format": {
"type": "string",
"enum": ["abbrev", "numeric", "day_first"],
"default": "abbrev",
"x-options": {
"labels": {
"abbrev": "Sep 19",
"numeric": "9/19",
"day_first": "19 Sep"
}
}
}
}
```
Labels are **display only** — the stored value is still the enum value, so
adding them never changes a saved config. The map may be partial: any value
without a label keeps the humanised fallback. Older cores that predate this
support ignore `x-options` and render the fallback for every option, so a
plugin can ship labels without requiring a core upgrade.
Array-table columns (`x-widget: array-table`) accept the same
`x-options.labels` on a column definition, but their fallback is the **raw
value** rather than the humanised one, because those columns hold values such
as ticker symbols where `aapl` → "Aapl" would be wrong. Rows added in the
browser use the labels too (`array-table.js`), so a column reads the same
before and after a page reload.
## Marking Fields as Advanced (`x-advanced`) ## Marking Fields as Advanced (`x-advanced`)
Add `"x-advanced": true` to any top-level, non-object property to move it out Add `"x-advanced": true` to any top-level, non-object property to move it out
+137 -19
View File
@@ -68,6 +68,21 @@ class PluginAdapter:
# always the same opening items. # always the same opening items.
self._item_offsets: dict = {} self._item_offsets: dict = {}
# What the matching entry in _item_offsets is an offset *into*, as
# (kind, size). An offset only means anything against the content it
# was derived from, and there are three incompatible kinds:
#
# ('rows', n) index into a list of n images
# ('cuts', n) index into the n item boundaries of one image
# ('cols', w) pixel column in a w-wide image with no item boundaries
#
# Without this the offsets were reused across kinds — a plugin that
# returned one wide image on one fetch and several rows on the next had
# a pixel column of 1400 read back as a row index — and across content
# changes, where a column recorded against a 9,793px news strip pointed
# into unrelated headlines once the strip refreshed to 9,505px.
self._offset_shapes: dict = {}
logger.info( logger.info(
"PluginAdapter initialized: display=%dx%d", "PluginAdapter initialized: display=%dx%d",
self.display_width, self.display_height self.display_width, self.display_height
@@ -398,6 +413,88 @@ class PluginAdapter:
return 0 return 0
return int(self.display_width * ratio) return int(self.display_width * ratio)
def _resume_offset(self, plugin_id: str, shape: Tuple[str, int]) -> int:
"""
The plugin's stored rotation offset, if it still applies.
An offset is only meaningful against content shaped the way it was
when the offset was recorded. When the shape has changed — a different
number of rows, a re-rendered strip with different item boundaries —
the stored value points somewhere arbitrary, so rotation restarts.
Args:
plugin_id: Plugin identifier
shape: (kind, size) describing what an offset would index into now
Returns:
The stored offset, or 0 when it no longer applies
"""
if self._offset_shapes.get(plugin_id) != shape:
if plugin_id in self._item_offsets:
logger.info(
"[%s] Content is %s now, was %s — restarting the rotation "
"rather than resuming at a position that no longer means "
"anything", plugin_id, shape,
self._offset_shapes.get(plugin_id))
self._item_offsets.pop(plugin_id, None)
self._offset_shapes[plugin_id] = shape
return 0
return self._item_offsets.get(plugin_id, 0)
def _record_offset(
self, plugin_id: str, offset: int, shape: Tuple[str, int]
) -> None:
"""Store where the next window should resume, with what it indexes."""
if offset:
self._item_offsets[plugin_id] = offset
self._offset_shapes[plugin_id] = shape
else:
# A wrapped-to-zero rotation is the same as no state at all, and
# keeping the key would report a window as active when the next
# pass starts from the top anyway.
self._item_offsets.pop(plugin_id, None)
self._offset_shapes.pop(plugin_id, None)
def _clear_offset(self, plugin_id: str) -> None:
"""Forget any rotation state for a plugin."""
self._item_offsets.pop(plugin_id, None)
self._offset_shapes.pop(plugin_id, None)
def _merge_trailing_runt(self, end: int, width: int, budget: int) -> int:
"""
Extend a window to the end of the content when what would be left over
is too small to be worth its own pass.
Windows were placed by walking forward from the last one, which makes
the final window whatever happens to remain. Measured on a live panel
that produced a 1,840px stocks ticker splitting 1,492 + 348 — the
second pass showing seven seconds of content before cutting, which
reads as the display failing rather than as a rotation.
Absorbing the remainder overruns the budget by less than one window
floor, which is a better trade than a fragment: the budget is a guard
against one plugin holding the panel for minutes, not a hard limit.
Args:
end: Column the window would otherwise end at
width: Full content width
budget: Width budget being applied
Returns:
``end``, or ``width`` when the remainder is below the floor
"""
remainder = width - end
# Measured against the budget rather than the panel: snapping to item
# boundaries means an ordinary window already lands short of the budget
# (a 512px budget over 182px-pitch items yields 348px windows), so an
# absolute floor would merge windows that were never fragments. Half a
# budget separates "a short last pass" from "a sliver", and caps the
# overrun this can cause at 1.5 budgets.
floor = budget // 2
if 0 < remainder < floor:
return width
return end
def _apply_width_budget( def _apply_width_budget(
self, images: List[Image.Image], plugin_id: str, self, images: List[Image.Image], plugin_id: str,
plugin: Optional['BasePlugin'] = None plugin: Optional['BasePlugin'] = None
@@ -435,19 +532,20 @@ class PluginAdapter:
if not budget or total <= budget: if not budget or total <= budget:
# Fits, so reset rotation — the whole segment is being shown. # Fits, so reset rotation — the whole segment is being shown.
self._item_offsets.pop(plugin_id, None) self._clear_offset(plugin_id)
return images return images
if len(images) == 1: if len(images) == 1:
return [self._crop_to_budget(images[0], budget, plugin_id, mode)] return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
shape = ('rows', len(images))
if mode == 'truncate': if mode == 'truncate':
# Ordered content: always show from the top. Deliberately does not # Ordered content: always show from the top. Deliberately does not
# advance the offset, so the same opening items appear every time # advance the offset, so the same opening items appear every time
# rather than the viewer being shown the middle of a ranked list. # rather than the viewer being shown the middle of a ranked list.
start = 0 start = 0
else: else:
start = self._item_offsets.get(plugin_id, 0) % len(images) start = self._resume_offset(plugin_id, shape) % len(images)
selected: List[Image.Image] = [] selected: List[Image.Image] = []
used = 0 used = 0
consumed = 0 consumed = 0
@@ -472,7 +570,8 @@ class PluginAdapter:
plugin_id, budget, len(selected), len(images), used plugin_id, budget, len(selected), len(images), used
) )
else: else:
self._item_offsets[plugin_id] = (start + consumed) % len(images) self._record_offset(
plugin_id, (start + consumed) % len(images), shape)
logger.info( logger.info(
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) " "[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
"from offset %d; remainder deferred to a later cycle", "from offset %d; remainder deferred to a later cycle",
@@ -490,16 +589,13 @@ class PluginAdapter:
The cut is snapped to the nearest blank column so it does not slice The cut is snapped to the nearest blank column so it does not slice
through a glyph or logo and leave half a character at the panel edge. through a glyph or logo and leave half a character at the panel edge.
"""
if mode == 'truncate':
# Always the start of the strip, so a ranked table is never entered
# from the middle.
offset = 0
else:
offset = self._item_offsets.get(plugin_id, 0)
if offset >= img.width:
offset = 0
Rotation is tracked as an index into the strip's item boundaries rather
than as a pixel column, because a ticker re-renders between fetches. A
column recorded against one render points at unrelated content in the
next as soon as anything ahead of it changes width — a digit in a
price, a shorter headline. The Nth boundary stays the Nth boundary.
"""
# Cut only where the plugin left a real gap between items. Snapping to # Cut only where the plugin left a real gap between items. Snapping to
# any blank column used to pick the single-column gaps between # any blank column used to pick the single-column gaps between
# characters, splitting a word and orphaning its tail into the next # characters, splitting a word and orphaning its tail into the next
@@ -514,9 +610,17 @@ class PluginAdapter:
# budget exactly. The gap rule exists to protect discrete items # budget exactly. The gap rule exists to protect discrete items
# (words, ticker entries); it would be wrong to let a solid image # (words, ticker entries); it would be wrong to let a solid image
# escape the cap in its name. # escape the cap in its name.
end = min(offset + budget, img.width) #
# With no items to index, the offset here has to stay a column, so
# it is only reusable while the image keeps its width.
shape = ('cols', img.width)
offset = 0 if mode == 'truncate' else self._resume_offset(
plugin_id, shape)
end = self._merge_trailing_runt(
min(offset + budget, img.width), img.width, budget)
if mode != 'truncate': if mode != 'truncate':
self._item_offsets[plugin_id] = 0 if end >= img.width else end self._record_offset(
plugin_id, 0 if end >= img.width else end, shape)
logger.info( logger.info(
"[%s] Width budget %dpx: cropped continuous %dpx image to " "[%s] Width budget %dpx: cropped continuous %dpx image to "
"[%d:%d] (no item gaps of %dpx+ to align to)%s", "[%d:%d] (no item gaps of %dpx+ to align to)%s",
@@ -528,8 +632,15 @@ class PluginAdapter:
# Cut mid-gap so the content either side keeps some breathing room. # Cut mid-gap so the content either side keeps some breathing room.
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps}) cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
start = max((c for c in cuts if c <= offset), default=0) shape = ('cuts', len(cuts))
later = [c for c in cuts if c > start] index = 0 if mode == 'truncate' else self._resume_offset(
plugin_id, shape)
# Clamped rather than wrapped: a stale index past the end means the
# strip shrank, and restarting reads better than landing near the end.
start_index = index if 0 <= index < len(cuts) - 1 else 0
start = cuts[start_index]
later = cuts[start_index + 1:]
if not later: if not later:
end = img.width end = img.width
else: else:
@@ -537,15 +648,22 @@ class PluginAdapter:
# No boundary inside the budget: take the next one and overrun, # No boundary inside the budget: take the next one and overrun,
# because the alternative is cutting through an item. # because the alternative is cutting through an item.
end = max(within) if within else min(later) end = max(within) if within else min(later)
end = self._merge_trailing_runt(end, img.width, budget)
# Every candidate for `end` came from `cuts` (which includes img.width),
# so this always resolves; the fallback is defensive only.
end_index = cuts.index(end) if end in cuts else len(cuts) - 1
if mode != 'truncate': if mode != 'truncate':
# Next cycle resumes where this one stopped; wrap when the strip ends. # Next cycle resumes at the boundary this one stopped on; wrap when
self._item_offsets[plugin_id] = 0 if end >= img.width else end # the strip ends.
self._record_offset(
plugin_id, 0 if end >= img.width else end_index, shape)
logger.info( logger.info(
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] " "[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
"(%dpx) at item boundaries, %s", "(%dpx) at item boundaries %d-%d of %d, %s",
plugin_id, budget, img.width, start, end, end - start, plugin_id, budget, img.width, start, end, end - start,
start_index, end_index, len(cuts) - 1,
"showing the start only (overflow=truncate)" "showing the start only (overflow=truncate)"
if mode == 'truncate' else "window advances next cycle" if mode == 'truncate' else "window advances next cycle"
) )
+134
View File
@@ -0,0 +1,134 @@
"""Guard: enum dropdowns in the plugin config form honour x-options.labels.
The form derives an option's visible text from its value — underscores
replaced, title case applied ("day_first" -> "Day First"). That cannot
express every label a schema needs: "vs" reads as "Vs", and "abbrev" says
nothing about the "Sep 19" it produces. Schemas can supply x-options.labels
instead, the same convention the checkbox-group widget already uses.
These tests extract the enum <select> block *out of the shipped template*
and render that, so they exercise the production expression rather than a
copy of it. If the fallback or the lookup changes, these tests render the
changed code and fail — a duplicated fragment here would silently keep
passing.
"""
import re
from pathlib import Path
import pytest
from jinja2 import DictLoader, Environment
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CONFIG_FORM = (PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'partials'
/ 'plugin_config.html')
ARRAY_TABLE_JS = (PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js'
/ 'widgets' / 'array-table.js')
# The enum branch: from the `{% set enum_labels %}` line through `</select>`.
ENUM_BLOCK_RE = re.compile(
r"(\{%\s*set enum_labels\s*=.*?</select>)", re.S
)
def _shipped_enum_block() -> str:
"""Return the live enum <select> block lifted from plugin_config.html."""
source = CONFIG_FORM.read_text(encoding='utf-8')
match = ENUM_BLOCK_RE.search(source)
assert match, (
'could not find the enum <select> block in plugin_config.html — the '
'template changed shape and this guard needs updating'
)
return match.group(1)
def _render(prop: dict, value=None) -> str:
"""Render the shipped enum block with a minimal fixture."""
env = Environment(loader=DictLoader({'f': _shipped_enum_block()}),
autoescape=True)
return env.get_template('f').render(
prop=prop, value=value, field_id='fid', full_key='k'
)
def _option_labels(html: str) -> dict:
"""Map each rendered option's value to its visible text."""
return {
value: text.strip()
for value, text in re.findall(
r'<option value="([^"]*)"[^>]*>(.*?)</option>', html, re.S
)
}
def test_labels_are_used_when_supplied() -> None:
html = _render({
'enum': ['vs', 'date_time'],
'x-options': {'labels': {'vs': 'VS', 'date_time': 'Date and time'}},
})
assert _option_labels(html) == {'vs': 'VS', 'date_time': 'Date and time'}
def test_unlabelled_values_keep_the_humanised_fallback() -> None:
"""Schemas without labels must render exactly as they did before."""
html = _render({'enum': ['day_first', 'weekday']})
assert _option_labels(html) == {'day_first': 'Day First', 'weekday': 'Weekday'}
def test_partial_labels_fall_back_per_value() -> None:
"""A labels map covering some values leaves the rest humanised."""
html = _render({'enum': ['vs', 'day_first'],
'x-options': {'labels': {'vs': 'VS'}}})
assert _option_labels(html) == {'vs': 'VS', 'day_first': 'Day First'}
def test_option_values_are_unchanged_by_labelling() -> None:
"""Labels are display-only: the submitted value stays the enum value."""
html = _render({'enum': ['abbrev'],
'x-options': {'labels': {'abbrev': 'Sep 19'}}})
assert _option_labels(html) == {'abbrev': 'Sep 19'}
def test_selected_option_still_tracks_the_current_value() -> None:
"""Labelling must not disturb which option is marked selected."""
html = _render({'enum': ['abbrev', 'numeric'],
'x-options': {'labels': {'abbrev': 'Sep 19'}}},
value='numeric')
selected = re.search(r'<option value="([^"]+)"[^>]*selected', html)
assert selected and selected.group(1) == 'numeric'
@pytest.mark.parametrize('key', ['x-options', 'x_options'])
def test_both_option_key_spellings_work(key: str) -> None:
"""The template accepts either spelling, as its other widgets do."""
html = _render({'enum': ['vs'], key: {'labels': {'vs': 'VS'}}})
assert _option_labels(html) == {'vs': 'VS'}
def test_table_column_enum_falls_back_to_the_raw_value() -> None:
"""Array-table columns must not title-case values that were never labelled.
Those columns hold values such as ticker symbols, where "aapl" -> "Aapl"
would be wrong, so their fallback stays the raw value.
"""
source = CONFIG_FORM.read_text(encoding='utf-8')
assert 'col_labels.get(opt, opt)' in source, (
'array-table column options must fall back to the raw value, not the '
'humanised one'
)
def test_dynamically_added_table_rows_use_the_same_labels() -> None:
"""Rows added client-side must label options like the server-rendered ones.
array-table.js builds new rows in the browser; if it printed the raw value
a column would read differently before and after a page reload.
"""
js = ARRAY_TABLE_JS.read_text(encoding='utf-8')
assert 'function enumOptionLabel' in js, (
'array-table.js lost its enum label helper'
)
raw_option_text = re.findall(r'o\.textContent\s*=\s*opt\s*;', js)
assert not raw_option_text, (
'array-table.js renders an enum option as its raw value; it must go '
'through enumOptionLabel() so dynamic rows match server-rendered ones'
)
+202
View File
@@ -0,0 +1,202 @@
"""Guard: the update button works on branches without tracking information.
`git pull --rebase` fails outright on a branch with no upstream:
There is no tracking information for the current branch.
Please specify which branch you want to rebase against.
That is easy to land on — checking out a branch by name, restoring a
backup, or following a guide that names one — and the Tools tab reported it
as a bare "Update failed; check logs for details", which the user cannot act
on. resolve_pull_command() falls back to an explicit `origin <branch>` pull
when that remote branch exists, and returns an actionable message when it
does not.
These tests build real git repositories in a temp dir, so they exercise git's
actual behaviour rather than a mock of it.
"""
import subprocess
from pathlib import Path
import pytest
from web_interface.blueprints.api_v3 import (
checkout_branch,
is_valid_branch_name,
resolve_pull_command,
)
pytestmark = pytest.mark.skipif(
subprocess.run(['git', '--version'], capture_output=True).returncode != 0,
reason='git not available',
)
def _git(*args, cwd):
return subprocess.run(['git', *args], cwd=str(cwd),
capture_output=True, text=True, check=True)
@pytest.fixture()
def repos(tmp_path):
"""An 'origin' repo with a main branch, and a clone of it."""
origin = tmp_path / 'origin'
origin.mkdir()
_git('init', '--initial-branch=main', '--bare', cwd=origin)
work = tmp_path / 'work'
_git('clone', str(origin), str(work), cwd=tmp_path)
_git('config', 'user.email', 'test@example.com', cwd=work)
_git('config', 'user.name', 'Test', cwd=work)
(work / 'README.md').write_text('hello\n')
_git('add', 'README.md', cwd=work)
_git('commit', '-m', 'initial', cwd=work)
_git('push', '-u', 'origin', 'main', cwd=work)
return work
def test_branch_with_upstream_uses_a_plain_pull(repos):
args, note, error = resolve_pull_command(str(repos))
assert error is None
assert args == ['git', 'pull', '--rebase']
assert note == ''
def test_branch_without_upstream_falls_back_to_origin_branch(repos):
"""The reported bug: a local branch that also exists on origin."""
_git('push', 'origin', 'main:audit', cwd=repos)
_git('fetch', 'origin', cwd=repos)
# A branch created this way has no tracking information.
_git('checkout', '-b', 'audit', cwd=repos)
assert subprocess.run(['git', 'rev-parse', '--abbrev-ref', '@{u}'],
cwd=str(repos), capture_output=True).returncode != 0
args, note, error = resolve_pull_command(str(repos))
assert error is None
assert args == ['git', 'pull', '--rebase', 'origin', 'audit']
assert 'audit' in note
def test_pull_fallback_actually_succeeds(repos):
"""The fallback command must work, not merely look right."""
_git('push', 'origin', 'main:audit', cwd=repos)
_git('fetch', 'origin', cwd=repos)
_git('checkout', '-b', 'audit', cwd=repos)
args, _, error = resolve_pull_command(str(repos))
assert error is None
done = subprocess.run(args, cwd=str(repos), capture_output=True, text=True)
assert done.returncode == 0, done.stderr
def test_local_only_branch_reports_an_actionable_message(repos):
"""No upstream and no origin/<branch>: say so, don't just fail."""
_git('checkout', '-b', 'local-experiment', cwd=repos)
args, _, error = resolve_pull_command(str(repos))
assert args is None
assert error and 'local-experiment' in error
assert 'no origin/local-experiment' in error
def test_detached_head_reports_an_actionable_message(repos):
head = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=str(repos),
capture_output=True, text=True).stdout.strip()
_git('checkout', head, cwd=repos)
args, _, error = resolve_pull_command(str(repos))
assert args is None
assert error and 'detached HEAD' in error
def test_missing_directory_does_not_raise(tmp_path):
"""A bad path must return an error, not blow up the request."""
args, _, error = resolve_pull_command(str(tmp_path / 'nope'))
assert args is None
assert error
# ── branch switching ────────────────────────────────────────────────────────
@pytest.mark.parametrize('name', [
'main', 'audit', 'feat/thing', 'release-1.2', 'a_b.c',
])
def test_valid_branch_names_accepted(name):
assert is_valid_branch_name(name)
@pytest.mark.parametrize('name', [
'', ' ', 'a b', 'a;rm -rf /', '--upload-pack=evil', '-x',
'a..b', 'a\nb', 'x' * 201, 'branch$(whoami)', '../escape',
])
def test_unsafe_branch_names_rejected(name):
"""The value reaches a subprocess argument list, so refuse the exotic."""
assert not is_valid_branch_name(name)
def test_switch_to_remote_only_branch_creates_it_with_tracking(repos):
_git('push', 'origin', 'main:release', cwd=repos)
_git('fetch', 'origin', cwd=repos)
payload, code = checkout_branch(str(repos), 'release')
assert code == 200 and payload['status'] == 'success', payload
assert _git('branch', '--show-current', cwd=repos).stdout.strip() == 'release'
upstream = subprocess.run(['git', 'rev-parse', '--abbrev-ref', '@{u}'],
cwd=str(repos), capture_output=True, text=True)
assert upstream.stdout.strip() == 'origin/release'
def test_switching_attaches_tracking_so_pull_needs_no_fallback(repos):
"""The whole point: after switching, a plain `git pull` works."""
_git('push', 'origin', 'main:audit', cwd=repos)
_git('fetch', 'origin', cwd=repos)
payload, _ = checkout_branch(str(repos), 'audit')
assert payload['status'] == 'success'
args, note, error = resolve_pull_command(str(repos))
assert error is None
assert args == ['git', 'pull', '--rebase']
assert note == ''
def test_unknown_branch_is_reported_not_created(repos):
payload, code = checkout_branch(str(repos), 'does-not-exist')
assert code == 404
assert 'does-not-exist' in payload['message']
assert _git('branch', '--show-current', cwd=repos).stdout.strip() == 'main'
def test_local_edits_block_the_switch_and_name_the_files(repos):
_git('push', 'origin', 'main:other', cwd=repos)
_git('fetch', 'origin', cwd=repos)
_git('checkout', '-b', 'other', 'origin/other', cwd=repos)
(repos / 'README.md').write_text('changed on other\n')
_git('add', 'README.md', cwd=repos)
_git('commit', '-m', 'diverge', cwd=repos)
_git('checkout', 'main', cwd=repos)
(repos / 'README.md').write_text('uncommitted local edit\n')
payload, code = checkout_branch(str(repos), 'other')
assert code == 200 and payload['status'] == 'error'
assert payload['can_retry_with_stash'] is True
assert 'README.md' in payload['detail']
assert _git('branch', '--show-current', cwd=repos).stdout.strip() == 'main'
def test_stash_option_lets_the_switch_through_and_keeps_the_work(repos):
"""stash=True must switch *and* leave the edit recoverable."""
_git('push', 'origin', 'main:other', cwd=repos)
_git('fetch', 'origin', cwd=repos)
_git('checkout', '-b', 'other', 'origin/other', cwd=repos)
(repos / 'README.md').write_text('changed on other\n')
_git('add', 'README.md', cwd=repos)
_git('commit', '-m', 'diverge', cwd=repos)
_git('checkout', 'main', cwd=repos)
(repos / 'README.md').write_text('uncommitted local edit\n')
payload, code = checkout_branch(str(repos), 'other', stash=True)
assert code == 200 and payload['status'] == 'success', payload
assert 'stashed' in payload['message']
assert _git('branch', '--show-current', cwd=repos).stdout.strip() == 'other'
# The edit is not lost — it is on the stash.
assert 'switch to other' in _git('stash', 'list', cwd=repos).stdout
+175
View File
@@ -1643,3 +1643,178 @@ class TestPerPluginWidthBudget:
strip = canvas([(0, 5000)], width=5000) strip = canvas([(0, 5000)], width=5000)
adapter.get_content(NativePlugin([strip]), 'ticker') adapter.get_content(NativePlugin([strip]), 'ticker')
assert adapter._item_offsets.get('ticker', 0) > 0 assert adapter._item_offsets.get('ticker', 0) > 0
def ticker(item_widths, gap=32, height=DISPLAY_H):
"""
A strip of discrete items separated by real gaps, like a news or stocks
ticker. Wide enough gaps that blank_runs() sees item boundaries, which is
what puts _crop_to_budget on its item-aligned path rather than treating the
strip as one continuous block.
"""
width = sum(item_widths) + gap * (len(item_widths) - 1)
spans, x = [], 0
for w in item_widths:
spans.append((x, x + w))
x += w + gap
return canvas(spans, width=width, height=height)
class TestTrailingRuntWindow:
"""
A rotation's last window used to be whatever happened to be left over.
Measured on a live 512px panel, a 1,840px stocks ticker against a 1,536px
budget split 1,492 + 348 — the second pass showed seven seconds and cut.
"""
def test_a_barely_oversized_strip_is_shown_whole(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
# 1.2 budgets wide: splitting it can only ever produce a fragment.
strip = ticker([180] * 12) # 2160 + 352 gaps = 2512px vs 512 budget
assert strip.width > DISPLAY_W
adapter = adapter_with(content_padding=0,
max_plugin_width_ratio=strip.width / DISPLAY_W * 0.9)
shown = adapter.get_content(NativePlugin([strip]), 'stocks')[0]
assert shown.width == strip.width, "should absorb the runt, not split"
assert 'stocks' not in adapter._item_offsets
def test_no_window_in_a_rotation_is_a_fragment(self):
# Walk a long ticker all the way round; every pass must be worth
# showing rather than one of them being a leftover sliver.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
strip = ticker([150] * 40)
plugin = NativePlugin([strip])
widths, seen_offsets = [], set()
for _ in range(20):
adapter.invalidate_cache('news')
widths.append(adapter.get_content(plugin, 'news')[0].width)
offset = adapter._item_offsets.get('news', 0)
if offset in seen_offsets:
break
seen_offsets.add(offset)
assert len(widths) > 1, "a strip this long must take several passes"
# Item snapping means an ordinary window lands short of the budget, so
# the bar is "not a sliver" rather than "a full budget".
assert min(widths) >= DISPLAY_W // 2, (
"no window should be a fragment, got %r" % widths)
assert max(widths) <= DISPLAY_W * 1.5, (
"absorbing a runt must stay bounded, got %r" % widths)
def test_a_continuous_image_also_absorbs_its_runt(self):
# The no-item-gaps path had the same leftover problem.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
solid = canvas([(0, 700)], width=700) # 512 budget -> 512 + 188 runt
first = adapter.get_content(NativePlugin([solid]), 'chart')[0]
assert first.width == 700, "188px tail is not worth its own pass"
assert 'chart' not in adapter._item_offsets
def test_the_reported_stocks_case(self):
# The exact numbers logged on a 512px panel: an 1,840px stocks ticker
# against a 1,536px budget split 1,492 + 348, so every other appearance
# showed seven seconds of stocks and cut. It should now come through in
# one piece, 20% over budget being the better of the two outcomes.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
# 10 items of 152px with 32px gaps = 1520 + 288 = 1808, near enough.
strip = ticker([152] * 10)
assert DISPLAY_W * 3 < strip.width < DISPLAY_W * 4
widths = []
for _ in range(3):
adapter.invalidate_cache('stocks')
widths.append(adapter.get_content(
NativePlugin([strip]), 'stocks')[0].width)
assert widths == [strip.width] * 3, (
"a strip this close to the budget should be shown whole every "
"time, not split into a big pass and a sliver; got %r" % widths)
def test_a_genuinely_long_strip_still_gets_capped(self):
# Absorbing runts must not become "never cap anything".
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
strip = ticker([150] * 60)
shown = adapter.get_content(NativePlugin([strip]), 'long')[0]
assert shown.width < strip.width
assert shown.width <= DISPLAY_W * 2
class TestOffsetOutlivesItsContent:
"""
A rotation offset only means something against the content it was recorded
against. news re-rendered 9,793px -> 9,505px mid-rotation while its stored
column kept advancing, so the window pointed into unrelated headlines.
"""
def test_rotation_survives_items_changing_width(self):
# Same items, each a little wider — a price gaining a digit. The window
# should resume at the same *item*, not at a now-meaningless column.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
adapter.get_content(NativePlugin([ticker([150] * 40)]), 'stocks')
first = adapter._item_offsets.get('stocks')
assert first, "the first pass should leave a resume point"
adapter.invalidate_cache('stocks')
adapter.get_content(NativePlugin([ticker([158] * 40)]), 'stocks')
assert adapter._item_offsets.get('stocks', 0) > first, (
"same item count means the offset still applies and should advance")
def test_rotation_restarts_when_the_item_count_changes(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
adapter.get_content(NativePlugin([ticker([150] * 40)]), 'news')
assert adapter._item_offsets.get('news', 0) > 0
# A fresh headline set with fewer entries: the old position is
# meaningless, so the next pass starts at the top.
adapter.invalidate_cache('news')
shown = adapter.get_content(NativePlugin([ticker([150] * 25)]), 'news')[0]
expected = adapter.get_content(
NativePlugin([ticker([150] * 25)]), 'fresh')[0]
assert shown.width == expected.width
def test_a_row_index_is_never_read_back_as_a_pixel_column(self):
# The unit collision: _apply_width_budget stores an index into a list
# of rows, _crop_to_budget a column in one image, under the same key.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0)
rows = [canvas([(0, 200)], width=200) for _ in range(8)]
adapter.get_content(NativePlugin(rows), 'mixed')
assert adapter._item_offsets.get('mixed', 0) > 0
assert adapter._offset_shapes['mixed'][0] == 'rows'
# Now the same plugin returns one wide strip instead. The row index
# must not be read as a column into it: the strip is entered at the
# top, exactly as it would be for a plugin with no history at all.
strip = ticker([150] * 40)
adapter.invalidate_cache('mixed')
carried = adapter.get_content(NativePlugin([strip]), 'mixed')[0]
assert adapter._offset_shapes['mixed'][0] == 'cuts'
clean = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0)
assert carried.tobytes() == clean.get_content(
NativePlugin([strip]), 'clean')[0].tobytes()
def test_a_stale_index_past_the_end_restarts(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
strip = ticker([150] * 40)
adapter.get_content(NativePlugin([strip]), 'news')
# Force an index far beyond anything the current strip has, keeping the
# shape intact so the guard does not catch it first.
shape = adapter._offset_shapes['news']
adapter._item_offsets['news'] = 10_000
adapter.invalidate_cache('news')
shown = adapter.get_content(NativePlugin([strip]), 'news')[0]
assert shown.width > 0
assert adapter._offset_shapes['news'] == shape
def test_content_that_fits_clears_both_offset_and_shape(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
adapter.get_content(NativePlugin([ticker([150] * 40)]), 'shrink')
assert 'shrink' in adapter._offset_shapes
adapter.invalidate_cache('shrink')
adapter.get_content(NativePlugin([canvas([(0, 100)], width=100)]), 'shrink')
assert 'shrink' not in adapter._item_offsets
assert 'shrink' not in adapter._offset_shapes
+241 -4
View File
@@ -1631,6 +1631,161 @@ def get_health():
'data': {'status': 'unhealthy'} 'data': {'status': 'unhealthy'}
}), 500 }), 500
def _git_current_branch(project_dir):
"""Current branch name, or '' when detached or git fails."""
try:
r = subprocess.run(['git', 'branch', '--show-current'],
capture_output=True, text=True, timeout=10, cwd=str(project_dir))
return r.stdout.strip() if r.returncode == 0 else ''
except (subprocess.TimeoutExpired, OSError):
return ''
def _git_upstream(project_dir):
"""Configured upstream for the current branch (e.g. 'origin/main'), or ''."""
try:
r = subprocess.run(['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
capture_output=True, text=True, timeout=10, cwd=str(project_dir))
return r.stdout.strip() if r.returncode == 0 else ''
except (subprocess.TimeoutExpired, OSError):
return ''
def _git_remote_branch_exists(project_dir, branch):
"""True when origin/<branch> exists locally as a remote-tracking ref."""
if not branch:
return False
try:
r = subprocess.run(
['git', 'show-ref', '--verify', '--quiet', f'refs/remotes/origin/{branch}'],
capture_output=True, text=True, timeout=10, cwd=str(project_dir))
return r.returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
def resolve_pull_command(project_dir):
"""Work out how to pull, for branches with and without an upstream.
A plain ``git pull --rebase`` fails outright on a branch that has no
upstream ("There is no tracking information for the current branch"),
which is easy to end up on: checking out a branch by name, restoring a
backup, or following an install guide that names one. The update button
then reports a failure the user cannot act on.
Returns ``(args, note, error)``. When ``origin/<branch>`` exists the pull
is made explicit against it, so the update proceeds and the branch is
given tracking information afterwards.
"""
upstream = _git_upstream(project_dir)
if upstream:
return ['git', 'pull', '--rebase'], '', None
branch = _git_current_branch(project_dir)
if not branch:
return None, '', (
"This checkout is in a detached HEAD state, so there is no branch "
"to update. Switch to a branch first (Tools -> Switch branch)."
)
if _git_remote_branch_exists(project_dir, branch):
return (
['git', 'pull', '--rebase', 'origin', branch],
f"Branch '{branch}' had no upstream; pulled from origin/{branch} and set it as the upstream.",
None,
)
return None, '', (
f"Branch '{branch}' has no upstream and there is no origin/{branch} to "
f"pull from. Use Switch branch to move to a branch that exists on the "
f"remote, or push this one first."
)
_BRANCH_NAME_RE = re.compile(r'[A-Za-z0-9._/-]{1,200}')
def is_valid_branch_name(name):
"""Accept only plain branch names.
This value becomes a subprocess argument, so anything exotic is refused
rather than escaped. '..' is excluded because it is range syntax to git.
"""
if not name or not _BRANCH_NAME_RE.fullmatch(name):
return False
return '..' not in name and not name.startswith('-')
def checkout_branch(project_dir, target, stash=False):
"""Switch the checkout to `target`, returning (payload, http_status).
Split out of the route so it can be tested against real repositories.
Attaches tracking when the branch exists on origin, so the next
Pull Latest is a plain `git pull` rather than the no-upstream fallback.
"""
target = (target or '').strip()
if not target:
return {'status': 'error', 'message': 'Branch name required'}, 400
if not is_valid_branch_name(target):
return {'status': 'error', 'message': 'Invalid branch name'}, 400
try:
subprocess.run(['git', 'fetch', 'origin', '--prune'],
capture_output=True, text=True, timeout=60, cwd=project_dir)
local_exists = subprocess.run(
['git', 'show-ref', '--verify', '--quiet', f'refs/heads/{target}'],
capture_output=True, text=True, timeout=10, cwd=project_dir).returncode == 0
remote_exists = _git_remote_branch_exists(project_dir, target)
if not local_exists and not remote_exists:
return {'status': 'error',
'message': f"No branch '{target}' locally or on origin"}, 404
# Local edits block a checkout. Pull Latest already stashes for the
# same reason, so offer it here too -- but only when asked, never
# silently: putting someone's edits away unasked is worse than
# refusing the switch.
stash_note = ''
if stash:
stashed = subprocess.run(['git', 'stash', 'push', '-m', f'switch to {target}'],
capture_output=True, text=True, timeout=60, cwd=project_dir)
if stashed.returncode == 0 and 'No local changes' not in stashed.stdout:
stash_note = ' Local changes were stashed (recover them with git stash list).'
if local_exists:
co = subprocess.run(['git', 'checkout', target],
capture_output=True, text=True, timeout=60, cwd=project_dir)
else:
# -B so a stale local ref does not block the checkout.
co = subprocess.run(['git', 'checkout', '-B', target, f'origin/{target}'],
capture_output=True, text=True, timeout=60, cwd=project_dir)
if co.returncode != 0:
logger.warning("git checkout %s failed: %s", target, co.stderr)
return {
'status': 'error',
'message': f"Could not switch to '{target}'.",
# Keep git's full list of blocking files: naming them is the
# difference between an error the user can act on and one they
# cannot.
'detail': (co.stderr or '').strip(),
'can_retry_with_stash': 'would be overwritten by checkout' in (co.stderr or ''),
}, 200
if remote_exists:
subprocess.run(['git', 'branch', f'--set-upstream-to=origin/{target}', target],
capture_output=True, text=True, timeout=10, cwd=project_dir)
logger.info("Switched checkout to branch %s", target)
return {
'status': 'success',
'message': f"Now on '{target}'.{stash_note} Use Pull Latest to fetch its newest code.",
}, 200
except subprocess.TimeoutExpired:
return {'status': 'error', 'message': 'Timed out talking to git'}, 504
except OSError as exc:
logger.error("checkout_branch failed: %s", exc, exc_info=True)
return {'status': 'error', 'message': 'Could not switch branch'}, 500
def get_git_version(project_dir=None): def get_git_version(project_dir=None):
"""Get git version information from the repository""" """Get git version information from the repository"""
if project_dir is None: if project_dir is None:
@@ -1795,6 +1950,14 @@ def execute_system_action():
# Use PROJECT_ROOT instead of hardcoded path # Use PROJECT_ROOT instead of hardcoded path
project_dir = str(PROJECT_ROOT) project_dir = str(PROJECT_ROOT)
# Decide how to pull BEFORE stashing. If this checkout cannot be
# updated at all, stashing first would put the user's local changes
# away for an update that was never going to run.
pull_args, upstream_note, pull_error = resolve_pull_command(project_dir)
if pull_error:
logger.warning("git pull not attempted: %s", pull_error)
return jsonify({'status': 'error', 'message': pull_error})
# Check if there are local changes that need to be stashed # Check if there are local changes that need to be stashed
# Exclude plugins directory - plugins are separate repos and shouldn't be stashed with base project # Exclude plugins directory - plugins are separate repos and shouldn't be stashed with base project
# Use --untracked-files=no to skip untracked files check (much faster with symlinked plugins) # Use --untracked-files=no to skip untracked files check (much faster with symlinked plugins)
@@ -1849,15 +2012,28 @@ def execute_system_action():
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.warning("git rev-parse timed out before pull") logger.warning("git rev-parse timed out before pull")
# Perform the git pull # Perform the git pull. Branches without an upstream were given
# an explicit "origin <branch>" above so the update still works.
result = subprocess.run( result = subprocess.run(
['git', 'pull', '--rebase'], pull_args,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=60, timeout=60,
cwd=project_dir cwd=project_dir
) )
# Give the branch tracking information so the next pull is a plain
# `git pull` — otherwise every update repeats the fallback.
if result.returncode == 0 and upstream_note:
branch = _git_current_branch(project_dir)
if branch:
try:
subprocess.run(
['git', 'branch', f'--set-upstream-to=origin/{branch}', branch],
capture_output=True, text=True, timeout=10, cwd=project_dir)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.debug("could not set upstream for %s: %s", branch, exc)
# Return custom response for git_pull # Return custom response for git_pull
if result.returncode == 0: if result.returncode == 0:
pull_message = "Code updated successfully." pull_message = "Code updated successfully."
@@ -1865,6 +2041,8 @@ def execute_system_action():
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}" pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
if result.stdout and "Already up to date" not in result.stdout: if result.stdout and "Already up to date" not in result.stdout:
pull_message = f"Code updated successfully.{stash_info}" pull_message = f"Code updated successfully.{stash_info}"
if upstream_note:
pull_message = f"{pull_message} {upstream_note}"
# Keep Python dependencies in sync automatically: if the pull # Keep Python dependencies in sync automatically: if the pull
# changed a requirements file, install it now — users updating # changed a requirements file, install it now — users updating
@@ -1929,12 +2107,25 @@ def execute_system_action():
logger.warning("Post-update plugin purge failed: %s", purge_err) logger.warning("Post-update plugin purge failed: %s", purge_err)
else: else:
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr) logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
pull_message = "Update failed; check logs for details" # Show git's own first line: "check logs" leaves the user with
# nothing to act on, and these failures are usually actionable
# (conflicting local commits, no upstream, network).
detail = next((ln.strip() for ln in (result.stderr or '').splitlines()
if ln.strip()), '')
pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details"
return jsonify({ return jsonify({
'status': 'success' if result.returncode == 0 else 'error', 'status': 'success' if result.returncode == 0 else 'error',
'message': pull_message, 'message': pull_message,
}) })
elif action == 'checkout_branch':
# Switch branches from the Tools tab. Needed because a checkout
# that predates tracking (or a restored backup) can leave the pi
# on a branch the update button cannot pull.
result_payload, http_status = checkout_branch(
str(PROJECT_ROOT), data.get('branch') or '', stash=bool(data.get('stash')))
return jsonify(result_payload), http_status
elif action == 'restart_display_service': elif action == 'restart_display_service':
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'], result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
@@ -2078,18 +2269,64 @@ def get_git_info():
log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d) log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d)
remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d) remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
branch_name = branch.stdout.strip()
upstream = _git_upstream(d)
return jsonify({ return jsonify({
'branch': branch.stdout.strip(), 'branch': branch_name,
'dirty': bool(status.stdout.strip()), 'dirty': bool(status.stdout.strip()),
'status': status.stdout.strip(), 'status': status.stdout.strip(),
'recent_commits': log.stdout.strip() if log.returncode == 0 else '', 'recent_commits': log.stdout.strip() if log.returncode == 0 else '',
'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '', 'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '',
# Surfaced so the Tools tab can warn before the user clicks Pull
# Latest, rather than after it fails.
'upstream': upstream,
'can_pull': bool(upstream) or _git_remote_branch_exists(d, branch_name),
}) })
except Exception as e: except Exception as e:
logger.error("get_git_info failed: %s", e, exc_info=True) logger.error("get_git_info failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500 return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500
@api_v3.route('/system/git-branches', methods=['GET'])
def get_git_branches():
"""List branches available to switch to, for the Tools tab picker."""
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
d = str(PROJECT_ROOT)
try:
# Refresh remote refs so a branch created since the last fetch shows up.
subprocess.run([_GIT, 'fetch', 'origin', '--prune'],
capture_output=True, text=True, timeout=60, cwd=d)
local = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'],
capture_output=True, text=True, timeout=15, cwd=d)
remote = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin'],
capture_output=True, text=True, timeout=15, cwd=d)
if local.returncode != 0:
return jsonify({'status': 'error', 'message': 'Could not list branches'}), 500
local_names = [b for b in local.stdout.split() if b]
remote_names = []
for ref in remote.stdout.split() if remote.returncode == 0 else []:
name = ref.split('origin/', 1)[-1]
# origin/HEAD is a symbolic alias, not a branch a user can pick.
if name and name != 'HEAD' and name not in local_names:
remote_names.append(name)
return jsonify({
'status': 'success',
'current': _git_current_branch(d),
'upstream': _git_upstream(d),
'local': sorted(local_names),
'remote_only': sorted(remote_names),
})
except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Timed out talking to the remote'}), 504
except OSError as e:
logger.error("get_git_branches failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Failed to list branches'}), 500
@api_v3.route('/hardware/status', methods=['GET']) @api_v3.route('/hardware/status', methods=['GET'])
def get_hardware_status(): def get_hardware_status():
"""Return LED matrix hardware initialization status written by display_manager at startup.""" """Return LED matrix hardware initialization status written by display_manager at startup."""
@@ -160,6 +160,24 @@
// ─── Cell rendering ───────────────────────────────────────────────────── // ─── Cell rendering ─────────────────────────────────────────────────────
/**
* Visible text for one enum option.
*
* Mirrors the server-rendered table in plugin_config.html: a schema may
* supply x-options.labels, and anything unlabelled falls back to the raw
* value. Rows added here must match rows rendered by the template, or the
* same column would read differently before and after a page reload.
*
* @param {Object} colDef column (or property) schema
* @param {*} opt the enum value
* @returns {string} label to display
*/
function enumOptionLabel(colDef, opt) {
const options = (colDef && (colDef['x-options'] || colDef['x_options'])) || {};
const labels = options.labels || {};
return Object.prototype.hasOwnProperty.call(labels, opt) ? labels[opt] : opt;
}
/** /**
* Create one <td> for a display column. * Create one <td> for a display column.
*/ */
@@ -219,7 +237,7 @@
if (opt === null) return; if (opt === null) return;
const o = document.createElement('option'); const o = document.createElement('option');
o.value = opt; o.value = opt;
o.textContent = opt; o.textContent = enumOptionLabel(colDef, opt);
if (String(colValue) === String(opt)) o.selected = true; if (String(colValue) === String(opt)) o.selected = true;
sel.appendChild(o); sel.appendChild(o);
}); });
@@ -646,7 +664,7 @@
enumVals.forEach(opt => { enumVals.forEach(opt => {
if (opt === null) return; if (opt === null) return;
const o = document.createElement('option'); const o = document.createElement('option');
o.value = opt; o.textContent = opt; o.value = opt; o.textContent = enumOptionLabel(schema, opt);
if (String(currentVal) === String(opt)) o.selected = true; if (String(currentVal) === String(opt)) o.selected = true;
sel.appendChild(o); sel.appendChild(o);
}); });
@@ -121,14 +121,21 @@
</label> </label>
{% endif %} {% endif %}
{# Enum dropdown #} {# Enum dropdown. Option text comes from x-options.labels when the
schema supplies it -- the same convention the checkbox-group
widget already uses -- because humanising the raw value cannot
express every label: "vs" reads as "Vs", and "abbrev" says
nothing about the "Sep 19" it produces. Values without a label
fall back to the humanised form, so existing schemas render
exactly as before. #}
{% elif prop.enum %} {% elif prop.enum %}
{% set enum_labels = (prop.get('x-options') or prop.get('x_options') or {}).get('labels') or {} %}
<select id="{{ field_id }}" <select id="{{ field_id }}"
name="{{ full_key }}" name="{{ full_key }}"
class="form-select w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black"> class="form-select w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black">
{% for option in prop.enum %} {% for option in prop.enum %}
<option value="{{ option }}" {% if value == option %}selected{% endif %}> <option value="{{ option }}" {% if value == option %}selected{% endif %}>
{{ option|replace('_', ' ')|title }} {{ enum_labels.get(option, option|replace('_', ' ')|title) }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
@@ -569,10 +576,14 @@
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center" class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}> {% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
{% elif col_enum %} {% elif col_enum %}
{# Labels are opt-in here and the fallback stays the raw
value: table columns hold things like ticker symbols,
which must not be title-cased behind the user's back. #}
{% set col_labels = (col_def.get('x-options') or col_def.get('x_options') or {}).get('labels') or {} %}
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}" <select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white"> class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
{% for opt in col_enum %}{% if opt is not none %} {% for opt in col_enum %}{% if opt is not none %}
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ opt }}</option> <option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ col_labels.get(opt, opt) }}</option>
{% endif %}{% endfor %} {% endif %}{% endfor %}
</select> </select>
{% elif col_xwidget == 'date-picker' %} {% elif col_xwidget == 'date-picker' %}
@@ -31,6 +31,24 @@
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<!-- Switch branch -->
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">Branch</p>
<p class="text-xs text-gray-500 mt-0.5">Choose which branch this pi follows. Switching attaches tracking, so Pull Latest works afterwards.</p>
</div>
<div class="shrink-0 flex items-center gap-2">
<select id="branch-select" class="text-sm border border-gray-300 rounded-md px-2 py-2 bg-white max-w-[14rem]">
<option value="">Loading branches…</option>
</select>
<button id="btn-checkout-branch" onclick="checkoutBranch(false)"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-code-branch mr-2"></i>Switch
</button>
</div>
</div>
<div id="result-checkout-branch" class="hidden"></div>
<!-- Pull latest --> <!-- Pull latest -->
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div> <div>
@@ -467,6 +485,14 @@
</div>`; </div>`;
} }
if (d.upstream) {
html += `<p class="text-xs text-gray-500 mt-1"><i class="fas fa-link mr-1"></i>tracking <span class="font-mono">${escHtml(d.upstream)}</span></p>`;
} else if (d.can_pull) {
html += `<p class="text-xs text-blue-700 mt-1"><i class="fas fa-info-circle mr-1"></i>No upstream set; Pull Latest will use <span class="font-mono">origin/${escHtml(d.branch || '')}</span> and set it.</p>`;
} else {
html += `<p class="text-xs text-amber-700 mt-1"><i class="fas fa-triangle-exclamation mr-1"></i>No upstream and no matching branch on origin — Pull Latest cannot run. Switch to a branch that exists on the remote.</p>`;
}
if (d.remote_url) { if (d.remote_url) {
html += `<p class="text-xs text-gray-400 mt-1"><i class="fas fa-cloud mr-1"></i>${escHtml(d.remote_url)}</p>`; html += `<p class="text-xs text-gray-400 mt-1"><i class="fas fa-cloud mr-1"></i>${escHtml(d.remote_url)}</p>`;
} }
@@ -479,6 +505,84 @@
}); });
} }
// ── branch picker ─────────────────────────────────────────────────────
// A pi can end up on a branch with no tracking information (checked out by
// name, restored from a backup), where `git pull` refuses to run. Being
// able to see and change the branch from here beats needing SSH.
function loadBranches() {
const sel = document.getElementById('branch-select');
if (!sel) return;
fetch('/api/v3/system/git-branches')
.then(r => r.ok ? r.json() : r.json().then(d => Promise.reject(d.message || `HTTP ${r.status}`)))
.then(d => {
if (d.status === 'error') {
sel.innerHTML = `<option value="">${escHtml(d.message || 'unavailable')}</option>`;
sel.disabled = true;
return;
}
sel.innerHTML = '';
const add = (name, suffix) => {
const o = document.createElement('option');
o.value = name;
o.textContent = name + (suffix || '');
if (name === d.current) o.selected = true;
sel.appendChild(o);
};
(d.local || []).forEach(b => add(b, b === d.current ? ' (current)' : ''));
// Remote-only branches are checked out on demand.
(d.remote_only || []).forEach(b => add(b, ' (remote)'));
if (!sel.options.length) add('', 'no branches found');
})
.catch(err => {
sel.innerHTML = `<option value="">${escHtml(String(err))}</option>`;
sel.disabled = true;
});
}
window.checkoutBranch = function(stash) {
const sel = document.getElementById('branch-select');
const branch = sel && sel.value;
if (!branch) return;
setBusy('btn-checkout-branch', true);
const el = document.getElementById('result-checkout-branch');
if (el) el.classList.add('hidden');
fetch('/api/v3/system/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'checkout_branch', branch: branch, stash: !!stash})
})
.then(r => r.json().catch(() => ({status: 'error', message: `HTTP ${r.status}`})))
.then(d => {
const ok = d.status === 'success';
// Show git's own list of blocking files, then offer the single
// action that clears it. Stashing is never done unasked.
showResult('result-checkout-branch', ok, d.message || '', d.detail || '');
if (!ok && d.can_retry_with_stash && el) {
const retry = document.createElement('div');
retry.className = 'mt-2 flex items-center gap-2';
const label = document.createElement('span');
label.className = 'text-xs text-gray-700';
label.textContent = 'Stash these changes and switch anyway?';
const btn = document.createElement('button');
btn.className = 'inline-flex items-center px-2 py-1 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50';
btn.textContent = 'Stash and switch';
btn.onclick = function() { window.checkoutBranch(true); };
retry.appendChild(label);
retry.appendChild(btn);
el.appendChild(retry);
}
// Both panels describe the checkout, so refresh them together.
loadGitInfo();
loadBranches();
})
.catch(err => showResult('result-checkout-branch', false, String(err)))
.finally(() => setBusy('btn-checkout-branch', false));
};
// ── power supply diagnostics panel ──────────────────────────────────────── // ── power supply diagnostics panel ────────────────────────────────────────
// Reuses the same SSE stream (window.statsSource, set up in base.html) // Reuses the same SSE stream (window.statsSource, set up in base.html)
// that already drives the header badge/banner and Overview card, instead // that already drives the header badge/banner and Overview card, instead
@@ -810,6 +914,7 @@
// Load on first render; HTMX will have already swapped us in by this point. // Load on first render; HTMX will have already swapped us in by this point.
loadGitInfo(); loadGitInfo();
loadBranches();
// Plugin health: initial load + periodic refresh. Guard against duplicate // Plugin health: initial load + periodic refresh. Guard against duplicate
// timers if this partial is re-swapped in by HTMX; the handler re-resolves // timers if this partial is re-swapped in by HTMX; the handler re-resolves