feat: sync saved mobile search views (Closes #821)
All checks were successful
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-14 10:46:13 +00:00
parent 6314efb82b
commit 19772b5797
13 changed files with 593 additions and 4 deletions

View File

@ -68,6 +68,12 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success. remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path.
Search previews also Search previews also
let operators assign an eligible issue and add it to Today without starting or replacing active work. let operators assign an eligible issue and add it to Today without starting or replacing active work.
The queue action keeps the Search query, filters, results, and scroll position available for continued The queue action keeps the Search query, filters, results, and scroll position available for continued

View File

@ -121,6 +121,15 @@ textarea { resize: vertical; min-height: 120px; }
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; } .cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; } .cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; } .cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; }
.saved-searches { margin-top:8px; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
.saved-search-header, .saved-search-create, .saved-search-row, .saved-search-row-actions { display:flex; align-items:center; gap:8px; }
.saved-search-header { justify-content:space-between; }
.saved-search-create { margin-top:6px; }
.saved-search-create input { flex:1; min-width:0; }
.saved-search-list { display:grid; gap:4px; margin-top:6px; }
.saved-search-row { justify-content:space-between; border-top:1px solid #1f3a5f; padding-top:4px; }
.saved-search-open { flex:1; min-width:0; text-align:left; }
.saved-search-action { min-height:44px; }
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; } #cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; } .cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; } .cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
@ -733,6 +742,9 @@ textarea { resize: vertical; min-height: 120px; }
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); } .cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; } #cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
#cmd-search-repository { min-height:44px; width:100%; } #cmd-search-repository { min-height:44px; width:100%; }
.saved-search-create { align-items:stretch; }
.saved-search-create input, .saved-search-open, .saved-search-row-actions button { min-height:44px; }
.saved-search-row { align-items:stretch; }
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); } #cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-panel { width:100%; border-left:0; padding:14px; } .create-issue-panel { width:100%; border-left:0; padding:14px; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; } .pull-sheet-panel { width:100%; border-left:0; padding:14px; }

View File

@ -4613,6 +4613,9 @@
}, },
}); });
taskOverlayHistory.start(); taskOverlayHistory.start();
createSavedSearches.mount(
document, fetch, commandSearch, applySearchScope, taskOverlayHistory
).load();
qs('#open-palette').addEventListener('click', openCommandPalette); qs('#open-palette').addEventListener('click', openCommandPalette);
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close()); qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore()); qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());

View File

@ -451,6 +451,18 @@
<datalist id="cmd-search-repositories"></datalist> <datalist id="cmd-search-repositories"></datalist>
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span> <span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
</fieldset> </fieldset>
<section class="saved-searches" aria-labelledby="saved-searches-heading">
<div class="saved-search-header">
<strong id="saved-searches-heading">Saved searches</strong>
<span id="saved-search-status" class="small" aria-live="polite"></span>
</div>
<div class="saved-search-create">
<label for="saved-search-name" class="sr-only">Saved search name</label>
<input id="saved-search-name" type="text" maxlength="60" autocomplete="off" placeholder="Name this search" />
<button id="save-current-search" class="saved-search-action" type="button">Save search</button>
</div>
<div id="saved-search-list" class="saved-search-list" aria-label="Saved searches"></div>
</section>
<div id="cmd-search-action-status" class="small" aria-live="polite"></div> <div id="cmd-search-action-status" class="small" aria-live="polite"></div>
<button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button> <button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button>
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div> <div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
@ -1107,6 +1119,7 @@
<script src="static/security-center.js"></script> <script src="static/security-center.js"></script>
<script src="static/markdown.js"></script> <script src="static/markdown.js"></script>
<script src="static/commands.js"></script> <script src="static/commands.js"></script>
<script src="static/saved-searches.js"></script>
<script src="static/search-preview.js"></script> <script src="static/search-preview.js"></script>
<script src="static/search-defer.js"></script> <script src="static/search-defer.js"></script>
<script src="static/widgets.js"></script> <script src="static/widgets.js"></script>

159
frontend/saved-searches.js Normal file
View File

@ -0,0 +1,159 @@
(function (root, factory) {
const createSavedSearches = factory();
if (typeof module === 'object' && module.exports) module.exports = createSavedSearches;
if (root) root.createSavedSearches = createSavedSearches;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createSavedSearches(options) {
const fetchJson = options.fetchJson;
const createId = options.createId || (() => crypto.randomUUID().replace(/-/g, ''));
const onOpen = options.onOpen;
const onState = options.onState || (() => {});
let current = { revision:0, views:[] };
function snapshot() {
return { revision:current.revision, views:current.views.map(view => ({ ...view })) };
}
function publish(status, message) {
onState({ ...snapshot(), status, ...(message ? { message } : {}) });
}
function normalizeSearch(name, search, id) {
const cleanName = String(name || '').trim();
const query = String(search?.query || '').trim();
if (!cleanName || cleanName.length > 60) throw new Error('Name must be between 1 and 60 characters.');
if (query.length < 2 || query.length > 200) throw new Error('Search query must be between 2 and 200 characters.');
return {
id, name:cleanName, query,
kind:['all', 'issue', 'pull'].includes(search?.kind) ? search.kind : 'all',
state:['all', 'open', 'closed'].includes(search?.state) ? search.state : 'all',
repository:String(search?.repository || '').trim(),
};
}
async function replace(views) {
publish('saving');
try {
current = await fetchJson('api/v1/saved-searches', {
method:'PUT', headers:{ 'Content-Type':'application/json' },
body:JSON.stringify({ revision:current.revision, views }),
});
publish('ready');
return snapshot();
} catch (error) {
const conflict = error?.status === 409 && error?.payload?.detail?.snapshot;
if (conflict) {
current = conflict;
publish('conflict', 'Saved searches changed on another device.');
} else {
publish('error', 'Saved searches could not sync. Search still works.');
}
throw error;
}
}
return {
async load() {
publish('loading');
try {
current = await fetchJson('api/v1/saved-searches');
publish('ready');
} catch (error) {
publish('error', 'Saved searches could not load. Search still works.');
}
return snapshot();
},
save(name, search) {
if (current.views.length >= 20) return Promise.reject(new Error('Saved searches are limited to 20.'));
const created = normalizeSearch(name, search, createId());
return replace(current.views.concat(created));
},
rename(id, name) {
const existing = current.views.find(view => view.id === id);
if (!existing) return Promise.reject(new Error('Saved search no longer exists.'));
const changed = normalizeSearch(name, existing, existing.id);
return replace(current.views.map(view => view.id === id ? changed : view));
},
remove(id) {
if (!current.views.some(view => view.id === id)) return Promise.resolve(snapshot());
return replace(current.views.filter(view => view.id !== id));
},
open(id) {
const selected = current.views.find(view => view.id === id);
if (selected) onOpen({ ...selected });
},
snapshot,
};
}
createSavedSearches.mount = function mountSavedSearches(document, fetch, search, applyScope, history) {
const query = selector => document.querySelector(selector);
const input = query('#cmd-input');
const request = async (url, init) => {
const response = await fetch(url, init);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.detail?.message || payload.detail || 'Saved Search request failed.');
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
};
let controller;
function render(state) {
const list = query('#saved-search-list');
const status = query('#saved-search-status');
list.replaceChildren();
status.textContent = ({loading:'Loading…', saving:'Syncing…', ready:'',
conflict:state.message, error:state.message})[state.status] || '';
state.views.forEach(view => {
const row = document.createElement('div');
row.className = 'saved-search-row';
const open = document.createElement('button');
open.type = 'button'; open.className = 'saved-search-open saved-search-action';
open.textContent = view.name; open.title = view.query;
open.addEventListener('click', () => controller.open(view.id));
const actions = document.createElement('div');
actions.className = 'saved-search-row-actions';
for (const action of ['Rename', 'Delete']) {
const button = document.createElement('button');
button.type = 'button'; button.className = 'saved-search-action'; button.textContent = action;
button.setAttribute('aria-label', action + ' ' + view.name);
button.addEventListener('click', async () => {
if (action === 'Rename') {
const name = globalThis.prompt('Rename saved search', view.name);
if (name !== null && name.trim()) await controller.rename(view.id, name).catch(() => {});
} else if (globalThis.confirm('Delete saved search “' + view.name + '”?')) {
await controller.remove(view.id).catch(() => {});
}
});
actions.append(button);
}
row.append(open, actions); list.append(row);
});
}
controller = createSavedSearches({ fetchJson:request, onState:render, onOpen:view => {
search.setQuery('');
input.value = view.query;
applyScope(view);
search.setQuery(view.query);
history.update({query:view.query, scope:view});
input.focus();
}});
query('#save-current-search').addEventListener('click', async () => {
const name = query('#saved-search-name').value.trim();
const current = {query:input.value.trim(), kind:query('#cmd-search-kind').value,
state:query('#cmd-search-state').value, repository:query('#cmd-search-repository').value.trim()};
if (!name || current.query.length < 2) {
query('#saved-search-status').textContent = 'Enter a name and at least 2 search characters.';
return;
}
await controller.save(name, current).then(() => { query('#saved-search-name').value = ''; })
.catch(error => { query('#saved-search-status').textContent = error.message; });
});
return controller;
};
return createSavedSearches;
});

View File

@ -17,6 +17,7 @@ const SHELL = [
BASE + 'static/security-center.js', BASE + 'static/security-center.js',
BASE + 'static/markdown.js', BASE + 'static/markdown.js',
BASE + 'static/commands.js', BASE + 'static/commands.js',
BASE + 'static/saved-searches.js',
BASE + 'static/search-preview.js', BASE + 'static/search-preview.js',
BASE + 'static/search-defer.js', BASE + 'static/search-defer.js',
BASE + 'static/widgets.js', BASE + 'static/widgets.js',

View File

@ -28,7 +28,7 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"today-timer": ( "today-timer": (
"static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", "static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/assign-and-start.js", "static/queue-today.js", "static/assign-and-start.js", "static/queue-today.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",

View File

@ -57,6 +57,7 @@ from src.push_notifications import (
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
from src.push_subscription_store import PushSubscriptionStore from src.push_subscription_store import PushSubscriptionStore
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
from src.security_event_store import SecurityEventStore, SecurityEventStoreError from src.security_event_store import SecurityEventStore, SecurityEventStoreError
from src.suggestion_engine import compute from src.suggestion_engine import compute
from src.later_store import LaterStore from src.later_store import LaterStore
@ -617,6 +618,20 @@ class LaterOperationBatch(BaseModel):
operations: list[LaterOperation] = Field(min_length=1, max_length=50) operations: list[LaterOperation] = Field(min_length=1, max_length=50)
class SavedSearchView(BaseModel):
id: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
name: str = Field(min_length=1, max_length=60)
query: str = Field(min_length=2, max_length=200)
kind: Literal["all", "issue", "pull"] = "all"
state: Literal["all", "open", "closed"] = "all"
repository: str = Field(default="", max_length=200)
class SavedSearchCollection(BaseModel):
revision: int = Field(ge=0)
views: list[SavedSearchView] = Field(max_length=20)
class NotificationLaterRequest(BaseModel): class NotificationLaterRequest(BaseModel):
wake_at: str = Field(min_length=1, max_length=100) wake_at: str = Field(min_length=1, max_length=100)
@ -1183,7 +1198,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next): async def prevent_live_api_caching(request, call_next):
response = await call_next(request) response = await call_next(request)
path = dashboard_auth.application_path(request) path = dashboard_auth.application_path(request)
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or ( if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/") path.startswith("/api/v1/repos/")
and path.endswith("/review") and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or ( ) or path.startswith("/api/v1/notifications") or (
@ -2114,6 +2129,12 @@ def _later_store() -> LaterStore:
) )
def _saved_search_store() -> SavedSearchStore:
return SavedSearchStore(
os.getenv("STACKCHAIN_SAVED_SEARCH_DB", str(_state_dir / "saved-searches.sqlite3"))
)
async def _confirmed_login() -> str: async def _confirmed_login() -> str:
try: try:
user = await asyncio.wait_for( user = await asyncio.wait_for(
@ -2129,6 +2150,49 @@ async def _confirmed_login() -> str:
return login.strip().lower() return login.strip().lower()
@app.get("/api/v1/saved-searches")
async def get_saved_searches(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_saved_search_store().get, login)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Saved Search synchronization is unavailable",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return snapshot
@app.put("/api/v1/saved-searches")
async def replace_saved_searches(payload: SavedSearchCollection):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_saved_search_store().replace,
login,
payload.revision,
[view.model_dump() for view in payload.views],
)
except SavedSearchConflict as exc:
raise HTTPException(
status_code=409,
detail={
"message": "Saved searches changed on another device.",
"snapshot": exc.snapshot,
},
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Saved Search synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/today") @app.get("/api/v1/today")
async def get_today_plan(): async def get_today_plan():
login = await _confirmed_login() login = await _confirmed_login()

130
src/saved_search_store.py Normal file
View File

@ -0,0 +1,130 @@
"""Durable, account-scoped saved Search views."""
import json
import re
import sqlite3
from pathlib import Path
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_VIEW_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
class SavedSearchConflict(ValueError):
"""Raised when a client attempts to replace a stale collection."""
def __init__(self, snapshot: dict):
super().__init__("saved searches changed on another device")
self.snapshot = snapshot
class SavedSearchStore:
def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
self._initialize()
def _initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS saved_searches (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
views TEXT NOT NULL
)
"""
)
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self.path, timeout=self.timeout)
@staticmethod
def _login(login: str) -> str:
normalized = login.strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
return {"revision": 0, "views": []} if row is None else {
"revision": int(row[0]), "views": json.loads(row[1])
}
def get(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = ?",
(self._login(login),),
).fetchone()
return self._snapshot(row)
def _normalize(self, views: list[dict]) -> list[dict]:
if not isinstance(views, list):
raise ValueError("views must be a list")
if len(views) > self.limit:
raise ValueError(f"saved searches are limited to {self.limit}")
normalized = []
seen = set()
for raw in views:
if not isinstance(raw, dict):
raise ValueError("saved search must be an object")
view_id = raw.get("id")
if not isinstance(view_id, str) or not _VIEW_ID.fullmatch(view_id):
raise ValueError("id is invalid")
if view_id in seen:
raise ValueError("saved search ids must be unique")
name = raw.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError("name is required")
name = name.strip()
if len(name) > 60:
raise ValueError("name must be at most 60 characters")
query = raw.get("query")
if not isinstance(query, str) or not 2 <= len(query.strip()) <= 200:
raise ValueError("query must be between 2 and 200 characters")
kind = raw.get("kind", "all")
state = raw.get("state", "all")
if kind not in {"all", "issue", "pull"}:
raise ValueError("kind is invalid")
if state not in {"all", "open", "closed"}:
raise ValueError("state is invalid")
repository = raw.get("repository", "")
if not isinstance(repository, str) or (repository and not _REPOSITORY.fullmatch(repository)):
raise ValueError("repository is invalid")
seen.add(view_id)
normalized.append({
"id": view_id,
"name": name,
"query": query.strip(),
"kind": kind,
"state": state,
"repository": repository,
})
return normalized
def replace(self, login: str, expected_revision: int, views: list[dict]) -> dict:
login = self._login(login)
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
raise ValueError("revision is invalid")
normalized = self._normalize(views)
serialized = json.dumps(normalized, separators=(",", ":"))
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
).fetchone()
current = self._snapshot(row)
if current["revision"] != expected_revision:
raise SavedSearchConflict(current)
revision = expected_revision + 1
connection.execute(
"INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, views=excluded.views",
(login, revision, serialized),
)
return {"revision": revision, "views": normalized}

View File

@ -74,8 +74,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions. # Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
assert len(first.runtime_gzip_bytes) <= 98 * 1024 assert len(first.runtime_gzip_bytes) <= 99 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source

View File

@ -0,0 +1,95 @@
import httpx
import pytest
from src import main
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
def view(view_id="release", name="Release queue", query="mobile", **scope):
return {
"id": view_id,
"name": name,
"query": query,
"kind": scope.get("kind", "issue"),
"state": scope.get("state", "open"),
"repository": scope.get("repository", "stackchain/stackchain-dashboard"),
}
def test_saved_searches_are_revisioned_ordered_and_account_scoped(tmp_path):
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3")
created = store.replace(" Timmy ", 0, [view(), view("reviews", "My reviews", "review", kind="pull")])
assert created == {"revision": 1, "views": [view(), view("reviews", "My reviews", "review", kind="pull")]}
assert store.get("timmy") == created
assert store.get("alexander") == {"revision": 0, "views": []}
with pytest.raises(SavedSearchConflict) as conflict:
store.replace("timmy", 0, [view(name="Stale overwrite")])
assert conflict.value.snapshot == created
def test_saved_searches_validate_and_bound_the_synced_collection(tmp_path):
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3", limit=2)
with pytest.raises(ValueError, match="limited to 2"):
store.replace("timmy", 0, [view("one"), view("two"), view("three")])
with pytest.raises(ValueError, match="unique"):
store.replace("timmy", 0, [view("same"), view("same")])
invalid = [
({**view(), "name": " "}, "name is required"),
({**view(), "query": "x"}, "query must be between"),
({**view(), "kind": "commit"}, "kind is invalid"),
({**view(), "state": "merged"}, "state is invalid"),
({**view(), "repository": "not-a-repository"}, "repository is invalid"),
]
for candidate, message in invalid:
with pytest.raises(ValueError, match=message):
store.replace("timmy", 0, [candidate])
@pytest.mark.anyio
async def test_saved_search_api_is_authenticated_csrf_protected_no_store_and_conflict_safe(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv(
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
"a-separate-session-signing-secret-with-enough-entropy",
)
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_SAVED_SEARCH_DB", str(tmp_path / "searches.sqlite3"))
async def user():
return {"id": 1, "login": "Timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
forbidden = await client.put(
"/api/v1/saved-searches", json={"revision": 0, "views": [view()]}
)
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
saved = await client.put(
"/api/v1/saved-searches", json={"revision": 0, "views": [view()]}, headers=headers
)
stale = await client.put(
"/api/v1/saved-searches",
json={"revision": 0, "views": [view(name="Overwrite")]},
headers=headers,
)
fetched = await client.get("/api/v1/saved-searches")
assert forbidden.status_code == 403
assert saved.status_code == 200
assert saved.json() == {"revision": 1, "views": [view()]}
assert stale.status_code == 409
assert stale.json()["detail"] == {
"message": "Saved searches changed on another device.",
"snapshot": saved.json(),
}
assert fetched.json() == saved.json()
assert fetched.headers["cache-control"] == "no-store"

View File

@ -0,0 +1,105 @@
import json
import subprocess
from pathlib import Path
SAVED_SEARCHES = Path(__file__).parents[1] / "frontend" / "saved-searches.js"
def run_node(script):
return json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
def test_saved_search_controller_loads_saves_opens_renames_and_deletes():
script = f"""
const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))});
const requests=[]; const opened=[]; const states=[];
let remote={{revision:0,views:[]}}; let sequence=0;
const controller=createSavedSearches({{
fetchJson:async (_url,options={{}})=>{{
requests.push(options.method||'GET');
if(!options.method)return remote;
const body=JSON.parse(options.body);remote={{revision:remote.revision+1,views:body.views}};return remote;
}},
createId:()=> 'view-'+(++sequence),
onOpen:view=>opened.push(view),
onState:state=>states.push(state),
}});
(async()=>{{
await controller.load();
await controller.save('Release queue',{{query:'mobile',kind:'issue',state:'open',repository:'stackchain/dashboard'}});
controller.open('view-1');
await controller.rename('view-1','Morning release queue');
await controller.remove('view-1');
process.stdout.write(JSON.stringify({{requests,opened,snapshot:controller.snapshot(),statuses:states.map(x=>x.status)}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
assert run_node(script) == {
"requests": ["GET", "PUT", "PUT", "PUT"],
"opened": [{
"id": "view-1",
"name": "Release queue",
"query": "mobile",
"kind": "issue",
"state": "open",
"repository": "stackchain/dashboard",
}],
"snapshot": {"revision": 3, "views": []},
"statuses": ["loading", "ready", "saving", "ready", "saving", "ready", "saving", "ready"],
}
def test_saved_search_controller_adopts_conflict_and_keeps_search_usable_on_failure():
script = f"""
const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))});
const states=[];let mode='conflict';
const server={{revision:4,views:[{{id:'remote',name:'Remote',query:'review',kind:'pull',state:'open',repository:''}}]}};
const controller=createSavedSearches({{
fetchJson:async (_url,options={{}})=>{{
if(!options.method)return {{revision:3,views:[]}};
if(mode==='conflict'){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:server}}}};throw error;}}
throw new Error('offline');
}},createId:()=> 'local',onOpen:()=>{{}},onState:state=>states.push([state.status,state.message||'']),
}});
(async()=>{{
await controller.load();
try{{await controller.save('Local',{{query:'mobile',kind:'all',state:'all'}})}}catch(_error){{}}
mode='offline';
try{{await controller.remove('remote')}}catch(_error){{}}
process.stdout.write(JSON.stringify({{snapshot:controller.snapshot(),states}}));
}})();
"""
result = run_node(script)
assert result["snapshot"] == {
"revision": 4,
"views": [{
"id": "remote", "name": "Remote", "query": "review",
"kind": "pull", "state": "open", "repository": "",
}],
}
assert result["states"][-1] == ["error", "Saved searches could not sync. Search still works."]
assert ["conflict", "Saved searches changed on another device."] in result["states"]
def test_mobile_search_renders_synced_saved_view_controls_and_runtime_wiring():
from tests.dashboard_bundle import dashboard_bundle_text
html = dashboard_bundle_text()
css = (SAVED_SEARCHES.parent / "dashboard.css").read_text()
dashboard = (SAVED_SEARCHES.parent / "dashboard.js").read_text()
assert '<section class="saved-searches" aria-labelledby="saved-searches-heading">' in html
assert 'id="saved-search-name"' in html
assert 'id="save-current-search"' in html
assert 'id="saved-search-list"' in html
assert 'id="saved-search-status"' in html
assert "createSavedSearches.mount(" in dashboard
saved_searches = SAVED_SEARCHES.read_text()
assert "search.setQuery('')" in saved_searches
assert "applyScope(view)" in saved_searches
assert "search.setQuery(view.query)" in saved_searches
assert ").load();" in dashboard
assert "static/saved-searches.js" in (SAVED_SEARCHES.parent.parent / "src" / "frontend_bundle.py").read_text()
assert ".saved-search-action { min-height:44px;" in css

View File

@ -787,6 +787,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/security-center.js", "/dashboard/static/security-center.js",
"/dashboard/static/markdown.js", "/dashboard/static/markdown.js",
"/dashboard/static/commands.js", "/dashboard/static/commands.js",
"/dashboard/static/saved-searches.js",
"/dashboard/static/search-preview.js", "/dashboard/static/search-preview.js",
"/dashboard/static/search-defer.js", "/dashboard/static/search-defer.js",
"/dashboard/static/widgets.js", "/dashboard/static/widgets.js",