feat(views): Edit rename/delete and the Favorites saved view

- Favorites is the first SAVED VIEWS row (star icon, name, real count).
  The count is the Favorites listing's own limit=1 total, follows the
  nav-count rules and is re-read after every confirmed star or unstar.
- #/favorites opens Images filtered to favorites. The workspace listing
  takes favorites=1: each selected root's favorites are read through the
  image_favorites primary key and matched against the cached scan with the
  toggle's case folding; a failed read is an error, never an unfiltered
  page. The cursor scope includes the flag.
- Saved Images state gains a required favorites boolean, so Save view on
  Favorites reopens the favorites.
- An open saved view's heading leads with Edit: "Edit saved view" with
  View name, Delete... (danger), Cancel and Save. Delete... confirms in the
  same dialog (never window.confirm). Toasts "Saved view renamed." and
  "Saved view deleted."; a delete returns to the view's plain page.
- shell-concept no longer maps Favorites out (Needs review stays, D50).
  Recorded as D51 in TESTING.md.
This commit is contained in:
Vito Sansevero
2026-10-21 06:05:26 -07:00
parent 042d6678e2
commit 8fff373902
18 changed files with 733 additions and 57 deletions
+23 -2
View File
@@ -445,8 +445,29 @@ Recorded shell deviations:
compares the rows; `tests/browser/workspace-saved-views.spec.mjs` covers saving,
reopening, the defaults that replace rejected values, and failed reads and saves.
- **D50**: no "Needs review" row in SAVED VIEWS until the tag-review slice (maintainer
ruling, 2026-10-21). Favorites arrives with the saved-views plan's Task 3; until then
the comparison maps it out with Needs review.
ruling, 2026-10-21). The comparison maps only that row out.
- **D51** (maintainer ruling, 2026-10-21, beyond the concept): SAVED VIEWS starts with
the concept's Favorites row (star icon, name, count). Its count is the Favorites
listing's own `limit=1` total (`/images/output?mode=workspace&favorites=1`), follows
the nav-count rules (hidden until loaded, absent when its read fails, never a
placeholder) and is re-read after every confirmed star or unstar, on any page.
`#/favorites` is Images filtered to favorites, named "Favorites" (concept
`renderImages`); `favorites=1` keeps only the selected roots' favorites, read per root
from `image_favorites` and matched against the scan with the toggle's case folding,
and a failed read is an error, never an unfiltered page. Unlike the concept, an
unstarred card stays on Favorites (showing ☆) until the next load, so the star can be
undone in place. Favorites neither restores nor replaces the Images return state.
Save view there saves `favorites: true`, so the view reopens the favorites. An open
saved view's heading leads with **Edit**: "Edit saved view" with "View name" and
Delete… (danger), Cancel and Save. Save re-reads the list, retitles the page and
toasts "Saved view renamed."; an empty name keeps the dialog open. Delete… swaps the
same dialog to "Delete saved view" (Cancel / Delete; never `window.confirm`); Delete
removes the view, replaces the dead route with the view's plain page and toasts
"Saved view deleted.". Failures toast "The view could not be renamed. Try again." or
"The view could not be deleted. Try again."; a view another tab already deleted counts
as deleted. `tests/browser/workspace-saved-views.spec.mjs` covers the row, count,
route, star updates, Save view on Favorites, Edit, and the failure paths;
`tests/test_image_favorites.py` covers `favorites=1`.
- **D29**: no prototype copy. There is no "Interactive concept" badge and no
"What changes & why" button. The footer reads "ComfyAssets · Local tools. Your
library.", and the title has no " concept" suffix. Save view's dialog action reads
+13
View File
@@ -137,3 +137,16 @@ class ImageFavoriteOperations:
)
found.update(path for (path,) in rows)
return found
def get_root_favorites(self, root_key: str) -> set[str]:
"""Every relative key favorited under *root_key* (a primary-key range).
The Favorites listing (``favorites=1``) intersects these with the
root's scan, so its size follows the root's favorites, not its files.
"""
conn = self.model.get_connection()
rows = conn.execute(
"SELECT relative_path FROM image_favorites WHERE root_key=?",
(root_key,),
)
return {path for (path,) in rows}
+6
View File
@@ -35,6 +35,8 @@ _STATE_FIELDS = {
"folder": ("text", 1024),
"media": ("choice", _MEDIA),
"tags": ("tags", None),
# The Favorites page's filter (#249 D51): a view saved there reopens it.
"favorites": ("bool", None),
},
"library": {
"q": ("text", 500),
@@ -92,6 +94,10 @@ def _validate_value(key: str, kind: str, limit, value: object) -> None:
):
raise ValueError(f"State '{key}' holds an invalid tag")
return
if kind == "bool":
if not isinstance(value, bool):
raise ValueError(f"State '{key}' must be true or false")
return
if not isinstance(value, str):
raise ValueError(f"State '{key}' must be a string")
if kind == "choice" and value not in limit:
+40 -4
View File
@@ -696,7 +696,9 @@ def _workspace_filters(query: Mapping) -> dict:
``q`` (at most 500 characters) matches the relative path, case-folded, and
the linked positive's text, title and tag names. ``category`` is exact,
``tags`` is a comma list that must all be present, ``sort=rating`` ranks by
the linked positive. Raises ValueError for any unknown or oversized value.
the linked positive, and ``favorites=1`` keeps only favorites (the
Favorites page, #249 D51). Raises ValueError for any unknown or oversized
value.
"""
text = query.get("q", "").strip()
folded = text.casefold()
@@ -707,8 +709,10 @@ def _workspace_filters(query: Mapping) -> dict:
tags = sorted(
{tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()}
)
favorites = query.get("favorites", "0")
if (
len(text) > 500
favorites not in ("0", "1")
or len(text) > 500
or len(folded) > 500
or media not in ("all", "image", "video")
or order not in WORKSPACE_ORDERS
@@ -727,6 +731,7 @@ def _workspace_filters(query: Mapping) -> dict:
"folder": folder,
"category": category,
"tags": tags,
"favorites": favorites == "1",
}
@@ -736,7 +741,12 @@ _COUNT_OMITTED = object()
def _filtered_page(
entries: list, start: int, size: int, filters: dict, links: dict | None
entries: list,
start: int,
size: int,
filters: dict,
links: dict | None,
favorites: frozenset | None = None,
) -> tuple[int, list, int | None]:
"""Count the *entries* the filters admit and take *size* from *start* on.
@@ -745,6 +755,7 @@ def _filtered_page(
category or tag filter admits only those, and ``q`` admits a filename match
or a linked-positive hit. Returns the total, the page and the number of
distinct linked positives among all admitted entries (None without links).
*favorites*, when given, admits only those listed paths (``favorites=1``).
"""
query, media, folder = filters["query"], filters["media"], filters["folder"]
members_only = filters["category"] is not None or bool(filters["tags"])
@@ -758,6 +769,8 @@ def _filtered_page(
continue
if media != "all" and (media == "video") != entry.video:
continue
if favorites is not None and entry.path not in favorites:
continue
match = None if links is None else links.get(entry.normalized)
if members_only and match is None:
continue
@@ -1146,6 +1159,7 @@ class ImageRoutesMixin:
[str(root) for root in output_dirs],
filters["category"],
filters["tags"],
filters["favorites"],
]
).encode()
).hexdigest()
@@ -1333,8 +1347,11 @@ class ImageRoutesMixin:
start = len(entries) - bisect_left(ascending, after, key=key)
else:
start = bisect_right(entries, after, key=key)
favorites = (
self._workspace_favorite_paths(listings) if filters["favorites"] else None
)
total, page, positive_count = _filtered_page(
entries, start, limit + 1, filters, links
entries, start, limit + 1, filters, links, favorites
)
if not needed and after is not None:
positive_count = _COUNT_OMITTED
@@ -2347,6 +2364,25 @@ class ImageRoutesMixin:
self._favorite_listing_cache[cache_key] = (files, listing)
return listing
def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset:
"""The listed paths of every favorite in the selected roots (blocking).
One primary-key read per root (get_root_favorites), matched against the
root's cached ``{relative key: listing spelling}``, so the work follows
the favorites rather than the files, and a key folds case exactly as
the toggle stored it (Windows). A favorite whose file is gone matches
nothing. A failed read raises: an unfiltered page must never pass for
the favorites.
"""
paths = set()
for _index, root, files in listings:
listing = self._favorite_listing(root, files)
for key in self.db.get_root_favorites(_favorite_root_key(root)):
listed = listing.get(key)
if listed is not None:
paths.add(root / listed)
return frozenset(paths)
def _workspace_favorites(self, page: list[tuple]) -> set[tuple[int, str]]:
"""``(root index, listing path)`` of the favorites among *page* entries."""
by_root: dict[tuple[int, Path], dict[str, str]] = {}
+2
View File
@@ -97,6 +97,8 @@ export async function openProduction(page,route,theme,motion='no-preference') {
await selectTheme(page,theme);
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
// SAVED VIEWS' Favorites count (D51), the concept's starred images in the concept fixture.
await expect(page.locator('#saved-nav a[data-route="favorites"] .nav-count')).toHaveCount(1);
}
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
+4 -4
View File
@@ -57,11 +57,11 @@ function remainingFooter(footer,spans) {
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
/**
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: the saved views only. D50: no "Needs
* review" row until the tag-review slice (maintainer ruling 2026-10-21). Favorites is the saved-views plan's Task 3.
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the
* saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21).
*/
function savedNav(nav) {
const rows=nav.rows.filter(row=>row.text!=='Favorites'&&row.text!=='Needs review');
const rows=nav.rows.filter(row=>row.text!=='Needs review');
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
}
@@ -231,7 +231,7 @@ for(const [width,height] of SIZES)test(`a saved view row matches the concept's $
const actual=await save('Save','#workspace-main');
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
.toEqual([['Golden hour',null]]);
.toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]);
});
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
+210 -7
View File
@@ -13,10 +13,17 @@ const filtersButton = page => toolbar(page).getByRole('button', { name: /^Filter
const imageCards = page => page.locator('article[data-image-key]');
const promptCards = page => page.locator('article[data-prompt-id]');
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
// The saved views' own rows, after the Favorites row (D51).
const viewRows = page => savedNav(page).locator('a[data-route^="saved/"]');
const favoritesRow = page => savedNav(page).locator('a[data-route="favorites"]');
const favoritesCount = page => favoritesRow(page).locator('.nav-count');
const editDialog = page => page.getByRole('dialog', { name: 'Edit saved view', exact: true });
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete saved view', exact: true });
const heading = page => main(page).getByRole('heading', { level: 1 });
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
const toast = page => page.locator('#workspace-toast');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [] };
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
async function refine(page, values) {
await filtersButton(page).click();
@@ -46,6 +53,8 @@ test.beforeEach(async ({ page, request, browserName }) => {
await expect(await request.post('/__test__/reset')).toBeOK();
page.savedErrors = [];
page.on('pageerror', error => page.savedErrors.push(error.message));
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
page.on('dialog', dialog => { page.savedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
// No listing read may fail on a saved view's values (an unknown Source would be a 400).
page.on('response', response => {
if (response.status() >= 400 && new URL(response.url()).pathname === '/prompt_manager/images/output') {
@@ -65,7 +74,7 @@ test('Save view saves the Images toolbar and Refine filters, and its sidebar row
await expect(imageCards(page)).toHaveCount(2);
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(savedNav(page).locator('a')).toHaveCount(0);
await expect(viewRows(page)).toHaveCount(0);
await search(page).fill('dogs');
await expect(imageCards(page)).toHaveCount(1);
@@ -134,7 +143,7 @@ test('an empty name is Saved view, and a Library view reopens the shelf filters'
await expect(name).toHaveValue('My workspace view');
await name.fill(' ');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(savedNav(page).getByRole('link')).toHaveText(['Saved view']);
await expect(viewRows(page)).toHaveText(['Saved view']);
expect((await listed(request))[0]).toMatchObject({ page: 'images', name: 'Saved view', state: IMAGE_DEFAULTS });
await page.goto(`${workspace}#/library`);
@@ -146,7 +155,7 @@ test('an empty name is Saved view, and a Library view reopens the shelf filters'
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await expect(name).toHaveValue('Search: dog');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(savedNav(page).getByRole('link')).toHaveText(['Saved view', 'Search: dog']);
await expect(viewRows(page)).toHaveText(['Saved view', 'Search: dog']);
const library = (await listed(request))[1];
expect(library).toMatchObject({ page: 'library', name: 'Search: dog', state: { q: 'dog', category: '', sort: 'newest', tags: ['dogs'] } });
@@ -199,13 +208,13 @@ test('an unknown saved view opens Images, and a failed listing offers Retry', as
: route.fallback());
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(main(page).getByRole('alert')).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(savedNav(page).locator('a')).toHaveCount(0);
await expect(viewRows(page)).toHaveCount(0);
failing = false;
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Cats');
await expect(search(page)).toHaveValue('cat');
await expect(promptCards(page)).toHaveCount(1);
await expect(savedNav(page).getByRole('link')).toHaveText(['Cats']);
await expect(viewRows(page)).toHaveText(['Cats']);
});
test('a failed save says so and adds nothing', async ({ page, request }) => {
@@ -217,6 +226,200 @@ test('a failed save says so and adds nothing', async ({ page, request }) => {
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be saved. Try again.');
await expect(savedNav(page).locator('a')).toHaveCount(0);
await expect(viewRows(page)).toHaveCount(0);
expect(await listed(request)).toEqual([]);
});
async function star(page, key) {
await page.locator(`article[data-image-key="${key}"] .star-button`).click();
}
test('Favorites is the first SAVED VIEWS row, with a real count that follows every star', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
let release;
const held = new Promise(resolve => { release = resolve; });
// Hidden until its read settles: never a placeholder "0".
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
&& url.searchParams.get('limit') === '1', async route => { await held; await route.fallback(); }, { times: 1 });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await expect(favoritesRow(page)).toHaveAttribute('href', '#/favorites');
await expect(favoritesCount(page)).toHaveCount(0);
release();
// The concept's row (app.js a332): the star icon, "Favorites", then the count.
await expect(favoritesCount(page)).toHaveText('0');
expect(await favoritesRow(page).evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE
? `text:${node.textContent}` : `${node.localName}.${node.getAttribute('class')}`)))
.toEqual(['svg.icon', 'text:Favorites', 'span.nav-count']);
expect(await savedNav(page).locator('a').evaluateAll(links => links.map(link => link.dataset.route))).toEqual(['favorites']);
await star(page, '1:shared/same.png');
await expect(toast(page)).toHaveText('Added to favorites.');
await expect(favoritesCount(page)).toHaveText('1');
// #/favorites: Images filtered to favorites, named Favorites (concept renderImages a417).
const listings = [];
page.on('request', item => {
const url = new URL(item.url());
if (url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') !== '1') listings.push(url.searchParams);
});
await favoritesRow(page).click();
await expect(page).toHaveURL(/#\/favorites$/);
await expect(heading(page)).toHaveText('Favorites');
await expect(heading(page)).toBeFocused();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Favorites');
await expect(page).toHaveTitle('Favorites · Prompt Manager');
await expect(favoritesRow(page)).toHaveAttribute('aria-current', 'page');
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await expect(main(page).locator('#result-count')).toHaveText(/^1 of 1 image/);
expect(listings.at(-1).get('favorites')).toBe('1');
// Unstarring on Favorites keeps the card (with ☆) so it can be undone in place; the count follows.
await star(page, '1:shared/same.png');
await expect(toast(page)).toHaveText('Removed from favorites.');
await expect(imageCards(page).locator('.star-button')).toHaveAttribute('aria-pressed', 'false');
await expect(favoritesCount(page)).toHaveText('0');
await star(page, '1:shared/same.png');
await expect(favoritesCount(page)).toHaveText('1');
// Favorites never replaces the Images return state: Images still lists everything.
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images/ }).click();
await expect(heading(page)).toHaveText('Images');
await expect(imageCards(page)).toHaveCount(2);
await page.goto(`${workspace}#/favorites`);
await expect(heading(page)).toHaveText('Favorites');
await expect(imageCards(page)).toHaveCount(1);
});
test('a failed Favorites count shows no badge', async ({ page }) => {
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
&& url.searchParams.get('limit') === '1', route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
await page.goto(`${workspace}#/images`);
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText('2');
await expect(favoritesRow(page)).toBeVisible();
await expect(favoritesCount(page)).toHaveCount(0);
// The refused count read is this test's own; nothing else failed.
page.savedErrors = page.savedErrors.filter(error => !/^503 .*favorites=1/.test(error));
});
test('Save view on Favorites reopens the favorites', async ({ page, request }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await star(page, '0:shared/same.png');
await expect(favoritesCount(page)).toHaveText('1');
await page.goto(`${workspace}#/favorites`);
await expect(imageCards(page)).toHaveCount(1);
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await saveDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Best');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
const [view] = await listed(request);
expect(view).toMatchObject({ page: 'images', name: 'Best', state: { ...IMAGE_DEFAULTS, favorites: true } });
await expect(viewRows(page)).toHaveText(['Best']);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await viewRows(page).click();
await expect(heading(page)).toHaveText('Best');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
});
test('Edit renames an open view, and deletes it only after confirming inside the dialog', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const view = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
// Plain pages have no Edit.
await expect(main(page).locator('.heading-actions').getByRole('button', { name: 'Edit', exact: true })).toHaveCount(0);
await viewRows(page).click();
await expect(heading(page)).toHaveText('Dogs');
// D51: Edit leads the heading's actions of an open view.
const edit = main(page).locator('.heading-actions > button').first();
await expect(edit).toHaveText('Edit');
await edit.click();
const dialog = editDialog(page);
const name = dialog.getByRole('textbox', { name: 'View name', exact: true });
await expect(name).toHaveValue('Dogs');
await expect(name).toBeFocused();
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
await expect(dialog.getByRole('button', { name: 'Delete…', exact: true })).toHaveClass(/\bdanger\b/);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(edit).toBeFocused();
// An empty name keeps the dialog open on its field.
await edit.click();
await name.fill(' ');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(name).toBeFocused();
await name.fill('Good dogs');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view renamed.');
await expect(viewRows(page)).toHaveText(['Good dogs']);
await expect(heading(page)).toHaveText('Good dogs');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Good dogs');
await expect(page).toHaveTitle('Good dogs · Prompt Manager');
await expect(edit).toBeFocused();
expect(await listed(request)).toEqual([{ ...view, name: 'Good dogs' }]);
// The rename kept the view's page state.
await expect(search(page)).toHaveValue('dogs');
await expect(imageCards(page)).toHaveCount(1);
// Delete… asks first; its Cancel deletes nothing.
await edit.click();
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
const confirm = deleteDialog(page);
await expect(confirm).toContainText('Delete “Good dogs”? Only the saved view is removed; its images and prompts stay.');
await expect(confirm.locator('.dialog-actions button')).toHaveText(['Cancel', 'Delete']);
await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused();
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(confirm).toHaveCount(0);
expect(await listed(request)).toHaveLength(1);
await edit.click();
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
await confirm.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view deleted.');
await expect(page).toHaveURL(/#\/images$/);
await expect(heading(page)).toHaveText('Images');
await expect(viewRows(page)).toHaveCount(0);
expect(await listed(request)).toEqual([]);
// The dead link left no history entry.
await page.goBack();
await expect(page).not.toHaveURL(new RegExp(`#/saved/${view.id}$`));
});
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Cats');
const edit = main(page).locator('.heading-actions > button').first();
await expect(edit).toHaveText('Edit');
let failing = true;
await page.route(`**${API}/${view.id}`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
: route.fallback());
await edit.click();
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be renamed. Try again.');
await expect(heading(page)).toHaveText('Cats');
await expect(viewRows(page)).toHaveText(['Cats']);
await edit.click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be deleted. Try again.');
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
expect(await listed(request)).toHaveLength(1);
failing = false;
await edit.click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view deleted.');
await expect(page).toHaveURL(/#\/library$/);
await expect(heading(page)).toHaveText('Prompt library');
await expect(viewRows(page)).toHaveCount(0);
});
+2
View File
@@ -18,6 +18,8 @@ const routes = [
['tags', 'Tags & categories', null],
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
['system', 'System', '/prompt_manager/admin'],
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
['favorites', 'Favorites', null],
];
async function countThemeWrites(page) {
+11
View File
@@ -128,6 +128,17 @@ test('all-source defaults are omitted and descending name sort remains supported
await stream.reset({root:'all',media:'all',sort:'name-desc'});
assert.equal(query.has('root'),false);assert.equal(query.has('media'),false);
assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40');
assert.equal(query.has('favorites'),false);
});
test('the Favorites page asks the listing for favorites only (#249 D51)', async () => {
const {createImageStream}=await source;let query;
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
await stream.reset({favorites:true,q:'fog'});
assert.equal(query.get('favorites'),'1');assert.equal(query.get('q'),'fog');
await stream.reset({favorites:false});
assert.equal(query.has('favorites'),false);
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
});
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
+56 -5
View File
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/saved-views.mjs');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [] };
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
@@ -12,8 +12,10 @@ test('a saved state always holds every page key and keeps only values its page a
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'] };
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true };
assert.deepEqual(normalizeViewState('images', full), full);
// D51: favorites is a boolean; anything else is the whole listing.
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
// Each rejected value falls back on its own; the rest of the view still applies.
assert.deepEqual(normalizeViewState('images', { ...full, sort: 'bogus', source: '-1', media: 'audio', tags: ['a,b'], extra: 1 }),
{ ...full, sort: 'newest', source: 'all', media: 'all', tags: [] });
@@ -61,11 +63,12 @@ test('the listing keeps usable views in order and normalizes their state', async
test('page filters and saved states convert both ways', async () => {
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'] };
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true };
const state = imagesViewState(filters);
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'] });
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true });
assert.deepEqual(imagesFilters(state), filters);
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [] });
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false });
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
@@ -84,6 +87,14 @@ function fakeApi() {
calls.push(['POST', url, body]);
return Promise.resolve({ success: true, view: { id: 9, ...body } });
},
patch(url, body) {
calls.push(['PATCH', url, body]);
return api.patchResult ?? Promise.resolve({ success: true, view: { id: 9, page: 'images', name: body.name, state: {} } });
},
del(url) {
calls.push(['DELETE', url]);
return api.deleteResult ?? Promise.resolve({ success: true, id: 9 });
},
};
return { api, pending };
}
@@ -136,3 +147,43 @@ test('creating a view posts the full state and re-reads the list', async () => {
const broken = createSavedViews({ ...api, post: () => Promise.resolve({ success: true, view: null }) });
await assert.rejects(broken.create('images', 'X', {}), TypeError);
});
test('renaming and deleting a view re-read the list, whatever the outcome', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const store = createSavedViews(api);
const renamed = store.rename(9, 'Dusk');
await flush();
assert.deepEqual(api.calls[0], ['PATCH', `${SAVED_VIEWS_URL}/9`, { name: 'Dusk' }]);
assert.deepEqual(api.calls[1], ['GET', SAVED_VIEWS_URL, 'no-store']);
pending[0].resolve({ success: true, views: [{ id: 9, page: 'images', name: 'Dusk', state: {} }] });
assert.deepEqual(await renamed, { id: 9, page: 'images', name: 'Dusk', state: IMAGE_DEFAULTS });
assert.equal(store.find(9).name, 'Dusk');
const removed = store.remove(9);
await flush();
assert.deepEqual(api.calls[2], ['DELETE', `${SAVED_VIEWS_URL}/9`]);
pending[1].resolve({ success: true, views: [] });
assert.equal(await removed, 9);
assert.equal(store.find(9), null);
// A view another tab already deleted is the outcome asked for; any other failure rejects, after a re-read.
api.deleteResult = Promise.reject(Object.assign(new Error('Not found'), { status: 404 }));
const gone = store.remove(4);
await flush();
pending[2].resolve({ success: true, views: [] });
assert.equal(await gone, 4);
api.deleteResult = Promise.reject(Object.assign(new Error('Locked'), { status: 500 }));
const failed = store.remove(4);
await flush();
pending[3].resolve({ success: true, views: [] });
await assert.rejects(failed, /Locked/);
api.patchResult = Promise.resolve({ success: true, view: null });
const invalid = store.rename(4, 'X');
await flush();
pending[4].resolve({ success: true, views: [] });
await assert.rejects(invalid, TypeError);
assert.equal(pending.length, 5);
assert.throws(() => store.rename(0, 'X'), TypeError);
assert.throws(() => store.remove('9'), TypeError);
});
+38 -9
View File
@@ -79,18 +79,19 @@ test('positive library deep links preserve the selected identity without duplica
test('nav counts reject negative, non-integer and missing totals', async () => {
const { summarizeNavCounts } = await source;
const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}});
const counts = summarizeNavCounts({images: {total: 24}, favorites: {total: 3}, library: {total: 0}, tags: {total: 7}});
assert.ok(counts instanceof Map);
assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]);
assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['library', 0], ['tags', 7]]);
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid});
const result = summarizeNavCounts({images: invalid, favorites: invalid, library: {total: 2}, tags: invalid});
assert.equal(result.get('images'), null, JSON.stringify(invalid));
assert.equal(result.get('favorites'), null);
assert.equal(result.get('library'), 2);
assert.equal(result.get('tags'), null);
}
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]);
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['favorites', null], ['library', null], ['tags', null]]);
});
test('nav counts settle independently so a slow images read never delays the others', async () => {
@@ -98,6 +99,7 @@ test('nav counts settle independently so a slow images read never delays the oth
let releaseImages;
const images = new Promise(resolve => { releaseImages = resolve; });
const api = {get: async url => {
if (url.includes('favorites=1')) return {success: true, total: 2};
if (url.startsWith('/prompt_manager/images')) return images;
if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3};
throw new Error('Unavailable');
@@ -105,14 +107,14 @@ test('nav counts settle independently so a slow images read never delays the oth
const settled = [];
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(settled.sort(), [['library', 3], ['tags', null]]);
assert.deepEqual(settled.sort(), [['favorites', 2], ['library', 3], ['tags', null]]);
releaseImages({success: true, total: 40});
const counts = await done;
assert.deepEqual(settled.at(-1), ['images', 40]);
assert.deepEqual([...counts], [['images', 40], ['library', 3], ['tags', null]]);
assert.deepEqual([...counts], [['images', 40], ['favorites', 2], ['library', 3], ['tags', null]]);
});
test('nav counts issue three bounded requests and drop only the failed count', async () => {
test('nav counts issue four bounded requests and drop only the failed count', async () => {
const { loadNavCounts } = await source;
const calls = [];
const signal = new AbortController().signal;
@@ -123,15 +125,42 @@ test('nav counts issue three bounded requests and drop only the failed count', a
return {success: true, total: 12};
}};
const counts = await loadNavCounts(api, signal);
assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]);
assert.deepEqual([...counts], [['images', 12], ['favorites', 12], ['library', null], ['tags', 5]]);
const urls = calls.map(([url]) => new URL(url, 'http://host'));
assert.deepEqual(urls.map(url => url.pathname),
['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
['/prompt_manager/images/output', '/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
assert.equal(urls[0].searchParams.has('favorites'), false);
// D51: the Favorites count is the Favorites page's own listing.
assert.equal(urls[1].searchParams.get('mode'), 'workspace');
assert.equal(urls[1].searchParams.get('favorites'), '1');
assert.ok(calls.every(([, used]) => used === signal));
});
test('a favorite change re-reads only the Favorites count', async () => {
const { loadNavCounts, FAVORITE_WRITE_COUNTS } = await source;
assert.deepEqual([...FAVORITE_WRITE_COUNTS], ['favorites']);
assert.ok(Object.isFrozen(FAVORITE_WRITE_COUNTS));
const calls = [];
const api = {get: async url => { calls.push(url); return {success: true, total: 0}; }};
const counts = await loadNavCounts(api, new AbortController().signal, () => {}, FAVORITE_WRITE_COUNTS);
assert.deepEqual(calls, ['/prompt_manager/images/output?mode=workspace&favorites=1&limit=1']);
// A real zero is a count, unlike an outstanding read.
assert.deepEqual([...counts], [['favorites', 0]]);
});
test('the Favorites route is native and resolves by its hash', async () => {
const { resolveRoute, isHandoff, routeTitle, routes } = await source;
const route = resolveRoute('#/favorites');
assert.equal(route.id, 'favorites');
assert.equal(route.label, 'Favorites');
assert.equal(routeTitle(route), 'Favorites');
assert.equal(isHandoff(route), false);
// No href: Overview lists no legacy card for it.
assert.equal(routes.find(item => item.id === 'favorites').href, undefined);
});
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
+132
View File
@@ -292,6 +292,134 @@ class TestImageFavoriteApi(ImageAPITestCase):
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
class TestWorkspaceFavoritesFilter(ImageAPITestCase):
"""``favorites=1`` on the workspace listing: the Favorites page (#249 D51)."""
async def tearDownAsync(self):
self.api.db.close_all()
assert_closed(self, self._temp_db.name, self._output_tmp.name)
await super().tearDownAsync()
async def page(self, status=200, **params):
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace", **params}
)
data = await response.json()
self.assertEqual(response.status, status, data)
return data
async def star(self, path, root_index=0, method="PUT"):
response = await self.client.request(
method, FAVORITE, json={"root_index": root_index, "path": path}
)
self.assertEqual(response.status, 200)
def make(self, *names):
for offset, name in enumerate(names):
target = make_png(self.output_dir / name)
os.utime(target, (1_700_000_000 + offset, 1_700_000_000 + offset))
async def test_lists_only_favorites_and_counts_them(self):
self.make("a.png", "sub/b.png", "c.png", "sub/d.png")
await self.star("a.png")
await self.star("sub/d.png")
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["sub/d.png", "a.png"])
self.assertEqual(data["total"], 2)
self.assertTrue(all(i["favorite"] is True for i in data["images"]))
# The nav count's read: limit=1 still reports every favorite's total.
self.assertEqual((await self.page(favorites="1", limit="1"))["total"], 2)
# Other filters intersect with it.
sub = await self.page(favorites="1", subfolder="sub")
self.assertEqual([i["path"] for i in sub["images"]], ["sub/d.png"])
self.assertEqual((await self.page(favorites="1", q="a.png"))["total"], 1)
self.assertEqual((await self.page(favorites="1", media="video"))["total"], 0)
# favorites=0 (and no parameter) is the whole listing.
self.assertEqual((await self.page(favorites="0"))["total"], 4)
self.assertEqual((await self.page())["total"], 4)
await self.star("a.png", method="DELETE")
self.assertEqual((await self.page(favorites="1"))["total"], 1)
async def test_cursor_pages_walk_only_favorites_in_every_order(self):
self.make("a.png", "b.png", "c.png", "d.png", "e.png")
for name in ("a.png", "c.png", "e.png"):
await self.star(name)
expected = {
"newest": ["e.png", "c.png", "a.png"],
"oldest": ["a.png", "c.png", "e.png"],
"name": ["a.png", "c.png", "e.png"],
"name-desc": ["e.png", "c.png", "a.png"],
"rating": ["e.png", "c.png", "a.png"],
}
for sort, names in expected.items():
seen, cursor = [], None
while True:
params = {"favorites": "1", "sort": sort, "limit": "1"}
if cursor:
params["cursor"] = cursor
data = await self.page(**params)
self.assertEqual(data["total"], 3, sort)
seen += [i["path"] for i in data["images"]]
cursor = data["next_cursor"]
if not data["has_more"]:
break
self.assertEqual(seen, names, sort)
# A cursor is bound to its filters: a whole-listing cursor is refused.
whole = await self.page(limit="1")
await self.page(400, favorites="1", cursor=whole["next_cursor"])
async def test_favorites_follow_their_own_root(self):
second = self.comfy_dir / "second"
make_png(second / "a.png")
make_png(self.output_dir / "a.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
await self.star("a.png", root_index=1)
data = await self.page(favorites="1")
self.assertEqual(
[(i["root_index"], i["path"]) for i in data["images"]], [(1, "a.png")]
)
self.assertEqual((await self.page(favorites="1", root="0"))["total"], 0)
self.assertEqual((await self.page(favorites="1", root="1"))["total"], 1)
async def test_windows_case_folded_favorite_is_listed(self):
make_png(self.output_dir / "Sub" / "A.png")
with patch("py.api.images._path_flavor", ntpath):
await self.star("Sub/A.png")
(self.output_dir / "Sub" / "A.png").rename(
self.output_dir / "Sub" / "a.png"
)
(self.output_dir / "Sub").rename(self.output_dir / "sub")
self.api.invalidate_gallery_cache()
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["sub/a.png"])
async def test_a_favorite_whose_file_is_gone_is_not_listed(self):
self.make("a.png", "b.png")
await self.star("a.png")
await self.star("b.png")
(self.output_dir / "b.png").unlink()
self.api.invalidate_gallery_cache()
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["a.png"])
self.assertEqual(data["total"], 1)
async def test_rejects_other_values(self):
for value in ("true", "2", "", "yes"):
data = await self.page(400, favorites=value)
self.assertFalse(data["success"])
async def test_store_failure_is_an_error_not_an_unfiltered_page(self):
self.make("a.png")
def broken(*_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
self.api.db.get_root_favorites = broken
data = await self.page(500, favorites="1")
self.assertFalse(data["success"])
self.assertNotIn("images", data)
class TestImageFavoriteSchema(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
@@ -314,6 +442,10 @@ class TestImageFavoriteSchema(unittest.TestCase):
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
)
self.assertEqual(db.get_image_favorites("output", []), set())
# One root's favorites, read through the primary key.
self.assertEqual(db.get_root_favorites("output"), {"a.png"})
self.assertEqual(db.get_root_favorites("other"), {"a.png"})
self.assertEqual(db.get_root_favorites("missing"), set())
many = [f"{n}.png" for n in range(2000)] + ["a.png"]
self.assertEqual(db.get_image_favorites("output", many), {"a.png"})
self.assertTrue(db.clear_image_favorite("output", "a.png"))
+12 -1
View File
@@ -35,6 +35,7 @@ IMAGES_STATE = {
"folder": "",
"media": "all",
"tags": ["fluffy"],
"favorites": False,
}
LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []}
@@ -145,6 +146,14 @@ class TestSavedViewsApi(ImageAPITestCase):
{**good, "state": {**IMAGES_STATE, "tags": ["a,b"]}},
{**good, "state": {**IMAGES_STATE, "tags": ["x" * 51]}},
{**good, "state": {**IMAGES_STATE, "tags": [f"t{n}" for n in range(21)]}},
# Favorites (the Favorites page's filter) is a JSON boolean.
{**good, "state": {**IMAGES_STATE, "favorites": "true"}},
{**good, "state": {**IMAGES_STATE, "favorites": 1}},
{**good, "state": {**IMAGES_STATE, "favorites": None}},
{
**good,
"state": {k: v for k, v in IMAGES_STATE.items() if k != "favorites"},
},
# Library has no source, folder or media, and its own sorts.
{"page": "library", "name": "L", "state": IMAGES_STATE},
{
@@ -185,10 +194,12 @@ class TestSavedViewsApi(ImageAPITestCase):
for sort in ("newest", "oldest", "name", "name-desc", "rating"):
for media in ("all", "image", "video"):
await self.create(state={"sort": sort, "media": media, "source": "3"})
favorite = await self.create(state={"favorites": True})
self.assertIs(favorite["state"]["favorites"], True)
for sort in ("newest", "oldest", "name", "rating"):
await self.create(page="library", state={"sort": sort, "tags": ["a b"]})
_, listed = await self.send("GET")
self.assertEqual(len(listed["views"]), 19)
self.assertEqual(len(listed["views"]), 20)
async def test_rename_and_delete_validate_id_and_body(self):
view = await self.create()
+4
View File
@@ -36,6 +36,10 @@ function queryFor(filters, batchSize) {
const sort = filters.sort ?? 'newest';
if (!IMAGE_SORTS.includes(sort)) throw new TypeError('Invalid sort filter');
query.set('sort', sort);
// The Favorites page (#249 D51): the listing keeps only favorites.
const favorites = filters.favorites ?? false;
if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter');
if (favorites) query.set('favorites', '1');
return query.toString();
}
+17 -4
View File
@@ -96,14 +96,23 @@ function deliverFavorite(key, favorite) {
else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
for (const target of embeddedFavoriteTargets) target(key, favorite);
}
// Told after every confirmed favorite change, wherever it was made (the shell's Favorites count, #249 D51).
const favoriteListeners = new Set();
/** Run `listener()` after each confirmed favorite change; returns the unsubscribe. */
export function onFavoriteChange(listener) {
favoriteListeners.add(listener);
return () => favoriteListeners.delete(listener);
}
/**
* An embedded collection (the history page's shared gallery and its All images and Repeat run dialogs, D47) has no
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
* `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view.
* `favorites` is the Favorites page (#249 D51): only favorites are listed, and it neither restores nor replaces the
* Images last visit.
*/
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
onSaveView = () => {}} = {}) {
favorites = false, onSaveView = () => {}} = {}) {
const scope = createScope();
const dispose = () => scope.dispose();
parentScope.signal.addEventListener('abort', dispose, {once:true});
@@ -117,7 +126,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scope.cleanup(() => inspection.dispose());
scope.cleanup(() => detailInspector?.dispose());
const saved = embedded || !savedState ? null : imagesFilters(savedState);
const visit = embedded || saved ? null : lastVisit;
const visit = embedded || saved || favorites ? null : lastVisit;
let density = visit?.density || 'comfortable';
let metrics = {...INITIAL_METRICS};
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
@@ -126,14 +135,16 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
let roots = [], folders = [];
const rendered = new Map();
const itemIndexes = new Map();
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved };
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved,
favorites: favorites || saved?.favorites === true };
filters.tags = [...filters.tags];
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
let savedScopePending = Boolean(saved) && (filters.root !== 'all' || filters.subfolder !== '');
scope.cleanup(() => {
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
if (!embedded) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
// A favorites-only collection is not the Images collection, so it never becomes the Images return state.
if (!embedded && !filters.favorites) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
@@ -370,6 +381,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
favoritePending.delete(key);
deliverFavorite(key, saved ? favorite : !favorite);
showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
// On Favorites an unstarred card stays (with ☆) until the next load, so the star can be undone in place (D51).
if (saved) for (const listener of favoriteListeners) listener();
}
scope.listen(grid, 'click', event => {
const card = event.target.closest('article[data-image-key]');
+43 -9
View File
@@ -1,16 +1,16 @@
import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES } from './state.mjs';
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages } from './images.mjs';
import { mountImages, onFavoriteChange } from './images.mjs';
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { mountVocabulary } from './vocabulary.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { bindCanvasLink, canvasHref } from './topbar.mjs';
import { renderHeading } from './heading.mjs';
import { renderHeading, pageHeading } from './heading.mjs';
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
import { createSavedViews, openSaveViewDialog, savedViewHash } from './saved-views.mjs';
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -73,10 +73,13 @@ function mountWorkspace() {
}
};
// SAVED VIEWS (#249 D16): the concept's saved-nav rows (search icon, name, no count), loaded with the shell and
// re-read after every change. /* D49 */ no "PROPOSED" badge; /* D50 */ no "Needs review" row.
// re-read after every change. /* D49 */ no "PROPOSED" badge; /* D50 */ no "Needs review" row. The first row is
// the concept's Favorites (star icon, count), kept across every re-read so its count stays (D51).
const [favoritesLink] = navLinks([['favorites', 'star']]);
savedNav.replaceChildren(favoritesLink);
const savedViews = createSavedViews(api, scope.signal);
scope.cleanup(savedViews.subscribe(({ views }) => {
savedNav.replaceChildren(...views.map(view => {
savedNav.replaceChildren(favoritesLink, ...views.map(view => {
const link = element('a');
link.append(createWorkspaceIcon('search'), document.createTextNode(view.name));
link.href = savedViewHash(view.id);
@@ -99,7 +102,7 @@ function mountWorkspace() {
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
loadNavCounts(api, request.signal, (_, count) => {
if (request.signal.aborted) return;
const link = primaryNav.querySelector(`[data-route="${id}"]`);
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
link?.querySelector('.nav-count')?.remove();
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
}, [id]);
@@ -108,6 +111,8 @@ function mountWorkspace() {
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
// Starring or unstarring anywhere (Images, Favorites, Library media) re-reads the Favorites count (D51).
scope.cleanup(onFavoriteChange(() => refreshCounts(FAVORITE_WRITE_COUNTS)));
refreshCounts(NAV_COUNT_ROUTES);
let viewScope;
let firstRender = true;
@@ -143,6 +148,7 @@ function mountWorkspace() {
if (saved) {
const page = routes.find(item => item.id === saved.page);
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
mountEditAction(saved, view);
return;
}
if (savedViews.status === 'ready') {
@@ -161,6 +167,33 @@ function mountWorkspace() {
main.append(retry);
}
// /* D51 */ The open view's Edit (maintainer ruling): first in the heading's actions. A rename re-reads the list and
// retitles the open page; a delete re-reads it and replaces the dead route with the view's plain page.
function mountEditAction(saved, view) {
const heading = pageHeading(main);
if (!heading) return;
const edit = createButton('Edit');
heading.actions.prepend(edit);
view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved,
onRename: async name => {
const renamed = await savedViews.rename(saved.id, name);
if (view.signal.aborted) return;
saved = renamed;
retitle(renamed.name);
},
onDelete: async () => {
await savedViews.remove(saved.id);
window.location.replace(`#/${saved.page}`);
} }));
}
function retitle(name) {
const { h1 } = pageHeading(main) ?? {};
if (h1) h1.textContent = name;
document.getElementById('workspace-breadcrumb').textContent = name;
document.title = `${name} · Prompt Manager`;
}
function renderPage(route, view, saved = null) {
document.title = `${routeTitle(route)} · Prompt Manager`;
// The concept's heading(); each view fills its description and actions.
@@ -169,9 +202,10 @@ function mountWorkspace() {
if (!firstRender) h1.focus();
firstRender = false;
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
if (route.id === 'images') {
if (route.id === 'images' || route.id === 'favorites') {
mountNewPromptAction(main, api, view);
images = mountImages(main, api, view, {savedState:saved?.state, onSaveView:saveView('images', view)});
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
onSaveView:saveView('images', view)});
return;
}
if (route.id === 'library') {
+106 -5
View File
@@ -7,13 +7,20 @@
* - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/<id>` (a1158–1162).
* - A view's state always holds every key of its page (the API requires them all); a value its page no longer
* accepts is dropped to that key's default, so an opened view applies what it can.
* - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name",
* Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`.
*/
import { openInputDialog } from './workspace-dialogs.mjs';
import { createButton } from '../shared/toolkit/core.js';
import { openInputDialog, openContentDialog, closeWorkspaceDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
export const SAVED_VIEWS_URL = '/prompt_manager/saved-views';
export const SAVED_TOAST = 'Saved view added to the sidebar.';
export const SAVE_FAILED_TOAST = 'The view could not be saved. Try again.';
export const RENAMED_TOAST = 'Saved view renamed.';
export const DELETED_TOAST = 'Saved view deleted.';
export const RENAME_FAILED_TOAST = 'The view could not be renamed. Try again.';
export const DELETE_FAILED_TOAST = 'The view could not be deleted. Try again.';
const MAX_NAME_LENGTH = 100;
const MAX_TAGS = 20;
const MAX_TAG_LENGTH = 50;
@@ -29,6 +36,8 @@ const FIELDS = Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'),
source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) },
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
favorites: { fallback: false, accepts: value => typeof value === 'boolean' },
}),
library: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
@@ -73,11 +82,12 @@ export const savedViewHash = id => `#/saved/${id}`;
/** Images filters (images.mjs `filters`) from a saved state, and the state of the current filters. */
export function imagesFilters(state) {
const view = normalizeViewState('images', state);
return { q: view.q, category: view.category, sort: view.sort, root: view.source, subfolder: view.folder, media: view.media, tags: view.tags };
return { q: view.q, category: view.category, sort: view.sort, root: view.source, subfolder: view.folder, media: view.media,
tags: view.tags, favorites: view.favorites };
}
export function imagesViewState(filters) {
return normalizeViewState('images', { q: filters.q, category: filters.category, sort: filters.sort, source: filters.root,
folder: filters.subfolder, media: filters.media, tags: filters.tags });
folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true });
}
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
@@ -91,7 +101,7 @@ export function libraryViewState(filters) {
/**
* The shell's list of saved views: `load()` re-reads it (a newer read supersedes an older one), `create()` saves one
* and re-reads the list, `subscribe(fn)` reports `{views, status}` on every change, and `settled()` resolves once no
* and re-reads the list, `rename(id, name)` and `remove(id)` change one and re-read it (a failure re-reads it too), `subscribe(fn)` reports `{views, status}` on every change, and `settled()` resolves once no
* read is in flight. `status` is 'loading', 'ready' or 'error'; a failed read keeps the last listed views.
*/
export function createSavedViews(api, signal) {
@@ -132,8 +142,36 @@ export function createSavedViews(api, signal) {
await load();
return view;
}
/** Run one change of a listed view, then re-read the list whatever its outcome (another tab may have changed it). */
async function change(send) {
try { return await send(); } finally { await load(); }
}
const viewUrl = id => {
if (!Number.isSafeInteger(id) || id < 1) throw new TypeError('Invalid saved view id');
return `${SAVED_VIEWS_URL}/${id}`;
};
function rename(id, name) {
const url = viewUrl(id);
return change(async () => {
const view = parseSavedView((await api.patch(url, { name }, { signal }))?.view);
if (!view) throw new TypeError('Invalid saved view');
return view;
});
}
function remove(id) {
const url = viewUrl(id);
return change(async () => {
try {
await api.del(url, { signal });
} catch (error) {
// Already gone (another tab deleted it): the outcome the user asked for.
if (error?.status !== 404) throw error;
}
return id;
});
}
return {
load, create,
load, create, rename, remove,
get views() { return views; },
get status() { return status; },
find: id => views.find(view => view.id === id) ?? null,
@@ -160,3 +198,66 @@ export function openSaveViewDialog({ scope, trigger, q, onSave }) {
}
} });
}
/**
* D51: the open view's Edit dialog. "View name" (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete, so nothing is removed by one press and
* no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the store's promise; the toast reports either
* outcome, and the shell navigates after a delete.
*/
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
const input = document.createElement('input');
input.value = view.name;
input.maxLength = MAX_NAME_LENGTH;
const remove = createButton('Delete…', 'danger');
const cancel = createButton('Cancel');
const save = createButton('Save', 'ca-primary');
const field = document.createElement('div');
field.className = 'field';
const label = document.createElement('label');
label.textContent = 'View name';
label.htmlFor = input.id = 'action-input';
field.append(label, input);
const actions = document.createElement('div');
actions.className = 'dialog-actions';
actions.append(remove, cancel, save);
cancel.addEventListener('click', () => closeWorkspaceDialog());
save.addEventListener('click', async () => {
const name = input.value.trim();
if (!name) { input.focus(); return; }
closeWorkspaceDialog();
if (name === view.name) return;
try {
await onRename(name);
showWorkspaceToast(RENAMED_TOAST);
} catch {
if (!scope.signal.aborted) showWorkspaceToast(RENAME_FAILED_TOAST);
}
});
remove.addEventListener('click', () => confirmDelete({ scope, trigger, view, onDelete }));
openContentDialog({ scope, trigger, title: 'Edit saved view', nodes: [field, actions] });
input.focus();
input.select();
}
function confirmDelete({ scope, trigger, view, onDelete }) {
const text = document.createElement('p');
text.textContent = `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`;
const actions = document.createElement('div');
actions.className = 'dialog-actions';
const cancel = createButton('Cancel');
const confirmButton = createButton('Delete', 'danger');
actions.append(cancel, confirmButton);
cancel.addEventListener('click', () => closeWorkspaceDialog());
confirmButton.addEventListener('click', async () => {
closeWorkspaceDialog();
try {
await onDelete();
showWorkspaceToast(DELETED_TOAST);
} catch {
showWorkspaceToast(DELETE_FAILED_TOAST);
}
});
openContentDialog({ scope, trigger, title: 'Delete saved view', nodes: [text, actions] });
cancel.focus();
}
+14 -7
View File
@@ -9,11 +9,13 @@ export const routes = [
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' },
{ id: 'overview', label: 'Overview' },
// SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it.
{ id: 'favorites', label: 'Favorites' },
{ id: 'system', label: 'System', href: '/prompt_manager/admin',
description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' },
];
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved']);
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites']);
/** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */
const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' });
@@ -47,14 +49,16 @@ export function summarizeStats(stats) {
});
}
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count.
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. Favorites (D51) is the
// Favorites page's own listing, so its count is what that page lists.
const NAV_COUNT_SOURCES = Object.freeze([
['images', '/prompt_manager/images/output?mode=workspace&limit=1'],
['favorites', '/prompt_manager/images/output?mode=workspace&favorites=1&limit=1'],
['library', '/prompt_manager/library?limit=1'],
['tags', '/prompt_manager/vocabulary/tags?limit=1'],
]);
/** Route id -> count for the WORKSPACE links; null when a page is missing or its total is not a count. */
/** Route id -> count for the sidebar links; null when a page is missing or its total is not a count. */
export function summarizeNavCounts(pages = {}) {
return new Map(NAV_COUNT_SOURCES.map(([id]) => {
const total = pages?.[id]?.total;
@@ -63,16 +67,19 @@ export function summarizeNavCounts(pages = {}) {
}
/**
* Read the three counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read
* settles, so a slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
* Read the counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read settles, so a
* slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
*/
/** The WORKSPACE routes that carry a count. */
/** The sidebar routes that carry a count (WORKSPACE, plus SAVED VIEWS' Favorites). */
export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id));
/** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */
export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
/** Read the given WORKSPACE counts (all by default), reporting each as its own read settles. */
/** The count a confirmed favorite change can change. */
export const FAVORITE_WRITE_COUNTS = Object.freeze(['favorites']);
/** Read the given sidebar counts (all by default), reporting each as its own read settles. */
export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_COUNT_ROUTES) {
const pages = {};
await Promise.all(NAV_COUNT_SOURCES.filter(([id]) => ids.includes(id)).map(async ([id, url]) => {