diff --git a/api/updates.py b/api/updates.py index d7793dc3..39721e37 100644 --- a/api/updates.py +++ b/api/updates.py @@ -113,12 +113,28 @@ def _run_git(args, cwd, timeout=10): def _dirty_suffix(path: Path, timeout=1) -> str: """Return a best-effort ``-dirty`` suffix without blocking version display.""" - out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout) - if ok: + try: + r = subprocess.run( + ['git', 'diff-index', '--quiet', 'HEAD', '--'], + cwd=str(path), + capture_output=True, + text=True, + timeout=timeout, + ) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return "" + if r.returncode == 0: return "" # diff-index exits 1 with no output for a dirty tree. Timeouts and real git # failures include a diagnostic; skip the suffix so the base version remains. - return "-dirty" if not out else "" + diagnostic = (r.stderr or r.stdout or "").strip() + if r.returncode != 1 or diagnostic: + return "" + diff, ok = _run_git(['diff', '--binary', 'HEAD', '--'], path, timeout=timeout) + if not (ok and diff): + return "-dirty" + digest = hashlib.sha1(diff.encode('utf-8', errors='replace')).hexdigest()[:8] + return f"-dirty-{digest}" def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None: diff --git a/static/sessions.js b/static/sessions.js index 443d848f..389e13da 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3866,20 +3866,24 @@ function renderSessionListFromCache(){ const offset=Math.sign(rawOffset)*(Math.abs(revealedOffset)+Math.sqrt(overshoot)*5); const progress=Math.min(1,Math.abs(revealedOffset)/72); const reveal=Math.abs(offset); - const iconScale=Math.min(1,Math.max(.01,progress*1.7)); + const actionRevealScale=1.15; + const iconScale=Math.min(1,Math.max(.01,progress*actionRevealScale)); const badgeSize=34*iconScale; const iconSize=18*iconScale; - const labelScale=Math.min(1,Math.max(.01,progress*1.7)); - const actionInset=0; + const labelScale=Math.min(1,Math.max(.01,progress*actionRevealScale)); + const actionOpacity=Math.min(1,Math.max(.01,progress*actionRevealScale)); + const actionInset=6; const tileGap=6; - const badgeStretch=Math.min(Math.max(0,reveal-34),overshoot*1.15,Math.max(0,reveal-badgeSize-actionInset-tileGap)); + const stretchStart=72/actionRevealScale; + const stretchProgress=Math.max(0,reveal-stretchStart); + const badgeStretch=Math.min(Math.max(0,reveal-34),stretchProgress*1.15,Math.max(0,reveal-badgeSize-actionInset-tileGap)); el.style.setProperty('--session-swipe-offset',offset+'px'); el.style.setProperty('--session-swipe-reveal',reveal+'px'); el.style.setProperty('--session-swipe-badge-size',badgeSize+'px'); el.style.setProperty('--session-swipe-icon-size',iconSize+'px'); el.style.setProperty('--session-swipe-label-scale',labelScale); el.style.setProperty('--session-swipe-badge-stretch',badgeStretch+'px'); - el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5)); + el.style.setProperty('--session-swipe-progress',actionOpacity); el.classList.toggle('swiping-right',offset>0); el.classList.toggle('swiping-left',offset<0); }; diff --git a/static/style.css b/static/style.css index e68d80a2..ab8e55b3 100644 --- a/static/style.css +++ b/static/style.css @@ -807,8 +807,8 @@ transform:scale(var(--session-swipe-label-scale,1)); transform-origin:top center; } - .session-swipe-affordance-right .session-swipe-action-stack{margin-left:0;} - .session-swipe-affordance-left .session-swipe-action-stack{margin-right:0;} + .session-swipe-affordance-right .session-swipe-action-stack{transform:translateX(6px);} + .session-swipe-affordance-left .session-swipe-action-stack{transform:translateX(-6px);} .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;will-change:transform;} .session-item.session-reflowing{transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;will-change:transform;} .session-item.swipe-committed, diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index d56d7589..e85c6a6b 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -123,16 +123,22 @@ def test_session_swipe_paint_uses_transform_only_exit(): assert "--session-swipe-progress" in paint assert "--session-swipe-badge-stretch" in paint assert "const reveal=Math.abs(offset);" in paint - assert "const iconScale=Math.min(1,Math.max(.01,progress*1.7));" in paint + assert "const actionRevealScale=1.15;" in paint + assert "const iconScale=Math.min(1,Math.max(.01,progress*actionRevealScale));" in paint assert "const badgeSize=34*iconScale;" in paint assert "const iconSize=18*iconScale;" in paint - assert "const labelScale=Math.min(1,Math.max(.01,progress*1.7));" in paint - assert "const actionInset=0;" in paint + assert "const labelScale=Math.min(1,Math.max(.01,progress*actionRevealScale));" in paint + assert "const actionOpacity=Math.min(1,Math.max(.01,progress*actionRevealScale));" in paint + assert "const actionInset=6;" in paint assert "const tileGap=6;" in paint - assert "const badgeStretch=Math.min(Math.max(0,reveal-34),overshoot*1.15,Math.max(0,reveal-badgeSize-actionInset-tileGap));" in paint + assert "const stretchStart=72/actionRevealScale;" in paint + assert "const stretchProgress=Math.max(0,reveal-stretchStart);" in paint + assert "const badgeStretch=Math.min(Math.max(0,reveal-34),stretchProgress*1.15,Math.max(0,reveal-badgeSize-actionInset-tileGap));" in paint assert "el.style.setProperty('--session-swipe-badge-size',badgeSize+'px');" in paint assert "el.style.setProperty('--session-swipe-icon-size',iconSize+'px');" in paint assert "el.style.setProperty('--session-swipe-label-scale',labelScale);" in paint + assert "el.style.setProperty('--session-swipe-progress',actionOpacity);" in paint + assert "Math.pow(progress,1.5)" not in paint assert "window.innerWidth+'px'" in complete assert "el.style.height=rect.height+'px'" in complete assert "requestAnimationFrame(()=>el.classList.add('swipe-removing'))" in complete @@ -189,8 +195,8 @@ def test_session_swipe_actions_use_circular_icon_badges(): assert "font-size:10px" in label assert "transform:scale(var(--session-swipe-label-scale,1))" in label assert "transform-origin:top center" in label - assert ".session-swipe-affordance-right .session-swipe-action-stack{margin-left:0;}" in STYLE_CSS - assert ".session-swipe-affordance-left .session-swipe-action-stack{margin-right:0;}" in STYLE_CSS + assert ".session-swipe-affordance-right .session-swipe-action-stack{transform:translateX(6px);}" in STYLE_CSS + assert ".session-swipe-affordance-left .session-swipe-action-stack{transform:translateX(-6px);}" in STYLE_CSS def test_session_removal_reflows_surviving_rows_smoothly(): diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py index 2e8733eb..9c18fd75 100644 --- a/tests/test_update_checker.py +++ b/tests/test_update_checker.py @@ -34,6 +34,59 @@ def _make_run_git_side_effect(*sequence): _MODULE = 'api.updates' +# --------------------------------------------------------------------------- +# Tests for version dirty suffix +# --------------------------------------------------------------------------- + +class TestVersionDirtySuffix: + def test_dirty_suffix_detects_diff_index_dirty_exit(self, tmp_path): + """git diff-index exits 1 with no output for normal dirty checkouts.""" + from api import updates + + diff_index = subprocess.CompletedProcess( + ['git', 'diff-index', '--quiet', 'HEAD', '--'], + 1, + stdout='', + stderr='', + ) + with patch(f'{_MODULE}.subprocess.run', return_value=diff_index), \ + patch(f'{_MODULE}._run_git', return_value=('changed file content', True)): + assert updates._dirty_suffix(tmp_path).startswith('-dirty-') + + def test_dirty_suffix_changes_with_diff_content(self, tmp_path): + """Dirty asset tokens should change when local tracked edits change.""" + from api import updates + + diff_index = subprocess.CompletedProcess( + ['git', 'diff-index', '--quiet', 'HEAD', '--'], + 1, + stdout='', + stderr='', + ) + with patch(f'{_MODULE}.subprocess.run', return_value=diff_index), \ + patch(f'{_MODULE}._run_git', return_value=('first diff', True)): + first = updates._dirty_suffix(tmp_path) + with patch(f'{_MODULE}.subprocess.run', return_value=diff_index), \ + patch(f'{_MODULE}._run_git', return_value=('second diff', True)): + second = updates._dirty_suffix(tmp_path) + assert first.startswith('-dirty-') + assert second.startswith('-dirty-') + assert first != second + + def test_dirty_suffix_ignores_git_errors(self, tmp_path): + """Real git diagnostics should not mark unknown/broken repos dirty.""" + from api import updates + + completed = subprocess.CompletedProcess( + ['git', 'diff-index', '--quiet', 'HEAD', '--'], + 128, + stdout='', + stderr='fatal: not a git repository', + ) + with patch(f'{_MODULE}.subprocess.run', return_value=completed): + assert updates._dirty_suffix(tmp_path) == '' + + # --------------------------------------------------------------------------- # Tests for _apply_update_inner() diagnostic paths # ---------------------------------------------------------------------------