mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-08 20:28:06 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
896ef4cb2e | ||
|
|
fc25a70d75 | ||
|
|
003312f4ff |
@@ -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.
|
||||
|
||||
## 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`)
|
||||
|
||||
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
||||
|
||||
@@ -68,6 +68,21 @@ class PluginAdapter:
|
||||
# always the same opening items.
|
||||
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(
|
||||
"PluginAdapter initialized: display=%dx%d",
|
||||
self.display_width, self.display_height
|
||||
@@ -398,6 +413,88 @@ class PluginAdapter:
|
||||
return 0
|
||||
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(
|
||||
self, images: List[Image.Image], plugin_id: str,
|
||||
plugin: Optional['BasePlugin'] = None
|
||||
@@ -435,19 +532,20 @@ class PluginAdapter:
|
||||
|
||||
if not budget or total <= budget:
|
||||
# Fits, so reset rotation — the whole segment is being shown.
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
self._clear_offset(plugin_id)
|
||||
return images
|
||||
|
||||
if len(images) == 1:
|
||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
||||
|
||||
shape = ('rows', len(images))
|
||||
if mode == 'truncate':
|
||||
# Ordered content: always show from the top. Deliberately does not
|
||||
# advance the offset, so the same opening items appear every time
|
||||
# rather than the viewer being shown the middle of a ranked list.
|
||||
start = 0
|
||||
else:
|
||||
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
||||
start = self._resume_offset(plugin_id, shape) % len(images)
|
||||
selected: List[Image.Image] = []
|
||||
used = 0
|
||||
consumed = 0
|
||||
@@ -472,7 +570,8 @@ class PluginAdapter:
|
||||
plugin_id, budget, len(selected), len(images), used
|
||||
)
|
||||
else:
|
||||
self._item_offsets[plugin_id] = (start + consumed) % len(images)
|
||||
self._record_offset(
|
||||
plugin_id, (start + consumed) % len(images), shape)
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
||||
"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
|
||||
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
|
||||
# any blank column used to pick the single-column gaps between
|
||||
# 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
|
||||
# (words, ticker entries); it would be wrong to let a solid image
|
||||
# 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':
|
||||
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(
|
||||
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
||||
"[%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.
|
||||
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)
|
||||
later = [c for c in cuts if c > start]
|
||||
shape = ('cuts', len(cuts))
|
||||
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:
|
||||
end = img.width
|
||||
else:
|
||||
@@ -537,15 +648,22 @@ class PluginAdapter:
|
||||
# No boundary inside the budget: take the next one and overrun,
|
||||
# because the alternative is cutting through an item.
|
||||
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':
|
||||
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
# Next cycle resumes at the boundary this one stopped on; wrap when
|
||||
# the strip ends.
|
||||
self._record_offset(
|
||||
plugin_id, 0 if end >= img.width else end_index, shape)
|
||||
|
||||
logger.info(
|
||||
"[%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,
|
||||
start_index, end_index, len(cuts) - 1,
|
||||
"showing the start only (overflow=truncate)"
|
||||
if mode == 'truncate' else "window advances next cycle"
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
@@ -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
|
||||
@@ -1643,3 +1643,178 @@ class TestPerPluginWidthBudget:
|
||||
strip = canvas([(0, 5000)], width=5000)
|
||||
adapter.get_content(NativePlugin([strip]), 'ticker')
|
||||
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
|
||||
|
||||
@@ -1631,6 +1631,161 @@ def get_health():
|
||||
'data': {'status': 'unhealthy'}
|
||||
}), 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):
|
||||
"""Get git version information from the repository"""
|
||||
if project_dir is None:
|
||||
@@ -1795,6 +1950,14 @@ def execute_system_action():
|
||||
# Use PROJECT_ROOT instead of hardcoded path
|
||||
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
|
||||
# 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)
|
||||
@@ -1849,15 +2012,28 @@ def execute_system_action():
|
||||
except subprocess.TimeoutExpired:
|
||||
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(
|
||||
['git', 'pull', '--rebase'],
|
||||
pull_args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
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
|
||||
if result.returncode == 0:
|
||||
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}"
|
||||
if result.stdout and "Already up to date" not in result.stdout:
|
||||
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
|
||||
# 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)
|
||||
else:
|
||||
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({
|
||||
'status': 'success' if result.returncode == 0 else 'error',
|
||||
'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':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
|
||||
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)
|
||||
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({
|
||||
'branch': branch.stdout.strip(),
|
||||
'branch': branch_name,
|
||||
'dirty': bool(status.stdout.strip()),
|
||||
'status': status.stdout.strip(),
|
||||
'recent_commits': log.stdout.strip() if log.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:
|
||||
logger.error("get_git_info failed: %s", e, exc_info=True)
|
||||
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'])
|
||||
def get_hardware_status():
|
||||
"""Return LED matrix hardware initialization status written by display_manager at startup."""
|
||||
|
||||
@@ -160,6 +160,24 @@
|
||||
|
||||
// ─── 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.
|
||||
*/
|
||||
@@ -219,7 +237,7 @@
|
||||
if (opt === null) return;
|
||||
const o = document.createElement('option');
|
||||
o.value = opt;
|
||||
o.textContent = opt;
|
||||
o.textContent = enumOptionLabel(colDef, opt);
|
||||
if (String(colValue) === String(opt)) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
@@ -646,7 +664,7 @@
|
||||
enumVals.forEach(opt => {
|
||||
if (opt === null) return;
|
||||
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;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
|
||||
@@ -121,14 +121,21 @@
|
||||
</label>
|
||||
{% 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 %}
|
||||
{% set enum_labels = (prop.get('x-options') or prop.get('x_options') or {}).get('labels') or {} %}
|
||||
<select id="{{ field_id }}"
|
||||
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">
|
||||
{% for option in prop.enum %}
|
||||
<option value="{{ option }}" {% if value == option %}selected{% endif %}>
|
||||
{{ option|replace('_', ' ')|title }}
|
||||
{{ enum_labels.get(option, option|replace('_', ' ')|title) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
@@ -569,10 +576,14 @@
|
||||
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 %}>
|
||||
{% 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 }}"
|
||||
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 %}
|
||||
<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 %}
|
||||
</select>
|
||||
{% elif col_xwidget == 'date-picker' %}
|
||||
|
||||
@@ -31,6 +31,24 @@
|
||||
</div>
|
||||
|
||||
<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 -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -467,6 +485,14 @@
|
||||
</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) {
|
||||
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 ────────────────────────────────────────
|
||||
// Reuses the same SSE stream (window.statsSource, set up in base.html)
|
||||
// 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.
|
||||
loadGitInfo();
|
||||
loadBranches();
|
||||
|
||||
// Plugin health: initial load + periodic refresh. Guard against duplicate
|
||||
// timers if this partial is re-swapped in by HTMX; the handler re-resolves
|
||||
|
||||
Reference in New Issue
Block a user