diff --git a/.changes/database-title-index-second-launch.md b/.changes/database-title-index-second-launch.md new file mode 100644 index 000000000..5ce1b76b4 --- /dev/null +++ b/.changes/database-title-index-second-launch.md @@ -0,0 +1,6 @@ +--- +type: perf +area: database +--- + +The second launch after setting up a new library no longer rebuilds the whole title search index before the app opens, which took several seconds on large catalogs (about 4.5 s per million titles). diff --git a/.changes/workspace-layout-slide.md b/.changes/workspace-layout-slide.md new file mode 100644 index 000000000..1e59813ba --- /dev/null +++ b/.changes/workspace-layout-slide.md @@ -0,0 +1,9 @@ +--- +type: fix +area: workspace +--- + +Opening a source from the dashboard no longer makes the page jump: the content +slides aside for the category panel, and the header controls slide over for +the Back button. The category list's search and sort buttons are in place +while the list loads. diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index f4fe0cb3f..bc8f0e6d1 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -33,6 +33,10 @@ import { type ElectronExitConfirmationOptions, prepareElectronApplication, } from './electron-process-lifecycle'; +import { + waitForXtreamImportCompleted, + xtreamPlaylistIdFromUrl, +} from './xtream-import-wait'; export const workspaceRoot = resolve(__dirname, '../../..'); export const electronMainPath = join( @@ -1769,7 +1773,13 @@ export async function waitForXtreamImportToFinish(page: Page): Promise { try { await overlay.waitFor({ state: 'visible', timeout: 5000 }); } catch { - // The overlay may already be gone by the time the test reaches this point. + // The overlay may already be gone, or not shown yet: it appears only + // once the portal has answered, so the import status decides below. + } + + const playlistId = xtreamPlaylistIdFromUrl(page.url()); + if (playlistId) { + await waitForXtreamImportCompleted(page, playlistId); } await expect(overlay).toHaveCount(0, { timeout: 30000 }); diff --git a/apps/electron-backend-e2e/src/xtream-import-wait.e2e.ts b/apps/electron-backend-e2e/src/xtream-import-wait.e2e.ts new file mode 100644 index 000000000..3397e6fff --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-import-wait.e2e.ts @@ -0,0 +1,174 @@ +import { createServer, request as forwardRequest } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + launchElectronApp, + openSources, + refreshSource, + resetMockServers, + test, + waitForXtreamCatalog, + xtreamMockServer, +} from './electron-test-fixtures'; +import { + readXtreamImportState, + xtreamPlaylistIdFromUrl, +} from './xtream-import-wait'; + +/** + * Longer than the 5 s the catalog wait gives the loading overlay to appear. + * The overlay appears only once the account check has answered, which a mock + * generating a large catalog on its first request can take this long to do. + */ +const ACCOUNT_INFO_DELAY_MS = 7_000; +/** Keeps series importing after the movie cards are already visible. */ +const SERIES_LIST_DELAY_MS = 3_000; + +interface DelayingProxy { + readonly origin: string; + /** Holds the account check and the series list from now on. */ + slowDown(): void; + close(): Promise; +} + +function portalDelay(url: URL): number { + if (!url.pathname.endsWith('/player_api.php')) { + return 0; + } + const action = url.searchParams.get('action'); + if (action === null || action === 'get_account_info') { + return ACCOUNT_INFO_DELAY_MS; + } + return action === 'get_series' ? SERIES_LIST_DELAY_MS : 0; +} + +/** Forwards GET requests to `target`, delayed by `portalDelay` once slowed. */ +async function startDelayingProxy( + target: string, + slow: boolean +): Promise { + const upstream = new URL(target); + let delaying = slow; + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://proxy.invalid'); + setTimeout( + () => { + const forwarded = forwardRequest( + { + headers: { ...request.headers, host: upstream.host }, + hostname: upstream.hostname, + method: request.method, + path: request.url, + port: upstream.port, + }, + (answer) => { + response.writeHead( + answer.statusCode ?? 502, + answer.headers + ); + answer.pipe(response); + } + ); + forwarded.on('error', () => response.destroy()); + forwarded.end(); + }, + delaying ? portalDelay(url) : 0 + ); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + return { + origin: `http://127.0.0.1:${port}`, + slowDown: () => { + delaying = true; + }, + close: () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + }; +} + +/** + * Closing the app right after the wait must not interrupt the import: the next + * launch reuses a type only when its status reads `completed` and rows exist, + * and global search finds nothing of a type whose rows are missing. + */ +async function expectCatalogComplete(page: Page): Promise { + const playlistId = xtreamPlaylistIdFromUrl(page.url()); + expect(playlistId).not.toBeNull(); + const imported = { + status: 'completed', + hasCategories: true, + hasContent: true, + }; + expect(await readXtreamImportState(page, playlistId as string)).toEqual({ + live: imported, + movie: imported, + series: imported, + }); +} + +test.describe('Electron Xtream catalog wait', () => { + test('@xtream @electron waits for every type when the portal answers after the overlay window', async ({ + dataDir, + request, + }) => { + test.setTimeout(120_000); + await resetMockServers(request, ['xtream']); + const proxy = await startDelayingProxy(xtreamMockServer, true); + try { + const app = await launchElectronApp(dataDir); + try { + await addXtreamPortal(app.mainWindow, { + name: 'Slow account check portal', + serverUrl: proxy.origin, + }); + await waitForXtreamCatalog(app.mainWindow); + await expectCatalogComplete(app.mainWindow); + } finally { + await closeElectronApp(app); + } + } finally { + await proxy.close(); + } + }); + + test('@xtream @electron waits for the re-import after a refresh, whose statuses still read completed', async ({ + dataDir, + request, + }) => { + test.setTimeout(150_000); + await resetMockServers(request, ['xtream']); + const proxy = await startDelayingProxy(xtreamMockServer, false); + try { + const app = await launchElectronApp(dataDir); + try { + const title = 'Slow refresh portal'; + await addXtreamPortal(app.mainWindow, { + name: title, + serverUrl: proxy.origin, + }); + await waitForXtreamCatalog(app.mainWindow); + + // A refresh deletes the catalog and re-imports it, leaving + // the three statuses at `completed` from the first import. + proxy.slowDown(); + await openSources(app.mainWindow); + await refreshSource(app.mainWindow, title, { confirm: true }); + await waitForXtreamCatalog(app.mainWindow); + await expectCatalogComplete(app.mainWindow); + } finally { + await closeElectronApp(app); + } + } finally { + await proxy.close(); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/xtream-import-wait.ts b/apps/electron-backend-e2e/src/xtream-import-wait.ts new file mode 100644 index 000000000..9bcf78d69 --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-import-wait.ts @@ -0,0 +1,117 @@ +import type { Page } from '@playwright/test'; + +/** Content types an Xtream import persists, in the app's status keys. */ +export const XTREAM_IMPORT_TYPES = ['live', 'movie', 'series'] as const; + +export type XtreamImportType = (typeof XTREAM_IMPORT_TYPES)[number]; + +/** What the database holds for one content type of a portal. */ +export interface XtreamImportTypeState { + /** `xtream-import-status::` in `app_state`. */ + readonly status: string | null; + readonly hasCategories: boolean; + readonly hasContent: boolean; +} + +export type XtreamImportState = Record; + +const XTREAM_ROUTE_PLAYLIST_ID = /\/workspace\/xtreams\/([^/?#]+)/; +/** Category types use the plural `movies`; content and status use `movie`. */ +const CATEGORY_TYPE: Record = { + live: 'live', + movie: 'movies', + series: 'series', +}; +const POLL_MS = 100; +const DEFAULT_TIMEOUT_MS = 60_000; + +/** The portal id of an `/workspace/xtreams//…` URL, or null. */ +export function xtreamPlaylistIdFromUrl(url: string): string | null { + const match = XTREAM_ROUTE_PLAYLIST_ID.exec(url); + return match ? decodeURIComponent(match[1]) : null; +} + +/** Status, category and content presence of every type, read through the bridge. */ +export async function readXtreamImportState( + page: Page, + playlistId: string +): Promise { + return page.evaluate( + async ({ id, types, categoryType }) => { + const entries = await Promise.all( + types.map(async (type) => [ + type, + { + status: await window.electron.dbGetAppState( + `xtream-import-status:${id}:${type}` + ), + hasCategories: await window.electron.dbHasCategories( + id, + categoryType[type] + ), + hasContent: await window.electron.dbHasContent( + id, + type + ), + }, + ]) + ); + return Object.fromEntries(entries); + }, + { + id: playlistId, + types: [...XTREAM_IMPORT_TYPES], + categoryType: CATEGORY_TYPE, + } + ) as Promise; +} + +/** + * Whether the portal's catalog is complete, judged the way the app judges it + * on its next start: it reuses a type only when the status reads `completed` + * and rows exist. A type without categories is empty on the portal (a + * live-only panel), so its status alone counts; but a portal without any + * category has not been imported yet, or a refresh has just deleted it. The + * status alone is not enough either: a refresh deletes the catalog and + * re-imports it without resetting the status. + */ +export function isXtreamImportComplete(state: XtreamImportState): boolean { + const types = XTREAM_IMPORT_TYPES.map((type) => state[type]); + return ( + types.some(({ hasCategories }) => hasCategories) && + types.every( + ({ status, hasCategories, hasContent }) => + status === 'completed' && (hasContent || !hasCategories) + ) + ); +} + +/** + * Waits until the portal's catalog is complete (`isXtreamImportComplete`). + * + * The loading overlay alone cannot prove that: it appears only once the + * portal has answered the account check and the import session starts. When + * that answer takes longer than the caller waits for the overlay (a mock + * generating a large catalog on its first request does), a check for "no + * overlay" passes before anything was imported, and closing the app then + * interrupts the import. A `cancelled` or `failed` status does not end the + * wait: the app writes no status when a retry starts, so it may be left over + * from an earlier attempt that is being retried now. + */ +export async function waitForXtreamImportCompleted( + page: Page, + playlistId: string, + timeoutMs = DEFAULT_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + let state = await readXtreamImportState(page, playlistId); + while (!isXtreamImportComplete(state)) { + if (Date.now() >= deadline) { + throw new Error( + `xtream-import-not-completed: ${JSON.stringify(state)}` + ); + } + await page.waitForTimeout(POLL_MS); + state = await readXtreamImportState(page, playlistId); + } +} diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 12d9c575b..cfbaecc47 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -1048,9 +1048,24 @@ was unchanged from passing launches (the same 119 statements before the first key, the usual 2 for the query, none afterwards), and every failure traced was the first launch after seeding. The journey fails such an iteration instead of measuring it, so a full run occasionally fails J4. -The cause is in the app, not the harness, and is not fixed here. No J4 -baseline exists yet; J4 counters join the ratchet once three runner runs -agree. + +The cause was the seed, not the query. The portal's import starts only +after its account check answers, and a freshly started mock generates the +`large` catalog on that first request (up to about 10 s on a busy machine). +`waitForXtreamImportToFinish` gave the loading overlay 5 s to appear and +then accepted "no overlay" as finished, so the seed closed the app while +the series import was still running. The template then held 4,000 live +channels and 4,000 movies but no series, and `system` matches only series. +A run stops at its first failed iteration, which is why the failure always +showed at the first launch after seeding. The wait now also requires the +catalog to be complete the way the app judges it on its next start +(`apps/electron-backend-e2e/src/xtream-import-wait.ts`): every type's +persisted status (`xtream-import-status::`) reads `completed` and +the type has rows, unless the portal has no categories of that type. Rows +are required because a refresh deletes the catalog without resetting the +status. `xtream-import-wait.e2e.ts` holds the account check past the overlay +window and fails without it. No J4 baseline exists yet; J4 counters join the +ratchet once three runner runs agree. ## `renderer.initialBytes` @@ -1273,15 +1288,49 @@ measured iterations of the 11 `master` runs from 2026-10-03 08:25 to | `launch/renderer.layoutShiftScoreSettled` | 0 | 0.235 in some iterations of 8 runs up to 2026-10-02 (hero flicker, #1782) | | `open-source/main.mockHttpRequestsToSettled` | 1 | identical | | `open-source/renderer.ipcCallsToFirstPage` | 17 | identical | -| `open-source/renderer.layoutShiftScore` | 0.233 | 0.221, then 0.222; 0.233 since #1814, never mixed within a run | +| `open-source/renderer.layoutShiftScore` | 0 | 0.221, then 0.222; 0.233 since #1814; 0 since #1895 (see below) | | `playback/renderer.httpRequestsToPlaying` | 2 | identical | | `playback/renderer.layoutShiftScore` | 0.001 | identical | `open-source/renderer.layoutShiftScore` read 0.222 in that window and 0.233 in every iteration of every `master` run from 84aef83a6 (#1814, page Back -buttons moved into the header) on, so its value is 0.233 with `evidenceRun` -37372780064 (b78224376). The 0.011 that #1814 added is not explained yet; -lowering it back is a separate change. +buttons moved into the header) on, so its value was 0.233 with `evidenceRun` +37372780064 (b78224376). Since #1895 it is 0 (`evidenceRun` 38147072417: +all 5 measured iterations on the runner read 0 with no shift recorded). + +Where the 0.233 came from (`evidence.layoutShift.shifts`, a 1280×720 window; +the score of a shift is the area of every node that moved times the largest +distance any of them moved, as a fraction of the viewport): + +| Shift, ms after the click | Nodes that moved | Score | +| ------------------------- | ---------------------------------------------------------- | ------ | +| ~40 (page swap) | `main.workspace-content` x 60 → 383, width −323 | 0.222 | +| | switcher, `label.search-field`, `div.header-actions` x +44 | 0.011 | +| ~50-70 (categories) | `div.context-header__actions` x −60, width +60 | 0.0001 | + +The first two are one frame: the frame that replaces the dashboard with the +portal page also inserts the 323 px context panel the dashboard has no room +for, and the history Back that #1814 shows once an in-app previous entry +exists. The header items move only 44 px, but a shift is scored with the +largest distance in it, the content pane's 323 px, which is why Back added +0.011 rather than about 0.0015. The third is the category search and sort +buttons appearing when the categories arrive. The skeletons of the category +list and the grid are replaced by content without any shift. Nothing was +hidden behind the startup overlay: the "Syncing playlist" overlay of the +cached catalog load flashes for about two frames after the shift and covers +no further one. + +The content frame and the header controls now slide into place instead of +jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout +applies in the same frame and only a transform animates, which Chromium does +not record as a layout shift. The category search and sort buttons render +disabled while the categories load. Locally (macOS, 2026-10-11) and on the runner +J2 then read 0 in every iteration with no shift recorded, J1 0 / 0, J3 0.001 +and J4 0; +`renderer.domMutationsToFirstPage` rose by 22 (the slide's class and style +changes) and the wall-clock entries did not move. Under +`prefers-reduced-motion` there is no slide, so a user with that preference +still sees the jump that the journey no longer measures. Being deterministic is not the same as being validated. Principle 3 of the plan promotes a counter to a guardrail once a PR has shown that lowering it diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 7a512adea..e0d213da2 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -550,7 +550,9 @@ Xtream-only row shape: as `Café` can still reach rows stored as either `Café` or `Cafe` before the worker's accent-insensitive ranking step. Existing databases rebuild this index once through the `migration:content-title-fts-trigram:v1` app-state - marker before legacy content cleanup/normalization migrations run; fresh + marker before legacy content cleanup/normalization migrations run (the + diacritics rebuild records that marker too, so it is never rebuilt twice; + see [VOD multi-source](vod-multi-source.md#short-titles-and-unicode)); fresh inserts, deletes, and title updates stay synchronized through SQLite triggers. 2. `source_type = "m3u"` rows come from M3U playlist payloads stored in `playlists.payload`. The worker uses SQL `payload LIKE` against channel diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 11c13119b..604aafbb7 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -833,7 +833,10 @@ titles ("Amélie" → "amelie") against an index built from the raw one — with folding, every accented title was invisible to the FTS path, and two identical `Amélie` entries produced no candidates at all. The tokenizer is fixed at CREATE time, so existing databases are recreated and rebuilt once behind -`migration:content-title-fts-remove-diacritics:v1`. +`migration:content-title-fts-remove-diacritics:v1`. That rebuild also records +`migration:content-title-fts-trigram:v1`, the plain rebuild it supersedes; +before it did, a new profile rebuilt the whole index a second time on its +second launch, on the main thread, right after the first catalog import. `remove_diacritics` needs SQLite 3.45+. The migration probes support on a temp table first and does nothing when the runtime rejects it, leaving the working diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index a161a3eee..243842ad3 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -127,6 +127,57 @@ synthesized mask, unlike any content layer) may span half the content area, and the backdrop-filtered controls must really be composited, so the check cannot pass on a page with nothing to clip. +### Layout slides + +When a route change adds or removes the context panel, the content frame +moves by the panel's width (323 px on desktop), and when Back or the phone +drawer toggle appears or leaves, the header controls after it move by the +slot's width (44 px). Both happen in the frame that swaps the page, so they +used to be one jump: a layout shift of 0.233 in the open-source journey, +0.222 of it the content frame (see +[performance journeys](./performance-journeys.md#j2-open-source-open-a-source-to-a-browsable-list)). + +`LayoutSlideDirective` (`workspace-shell/layout-slide.directive.ts`) turns +these jumps into a 180 ms slide, the duration of the context panel's own +width transition. The new layout applies at once; each target starts +translated back to the position it had and animates its transform to none +(FLIP), so nothing waits for the animation and Chromium records no layout +shift for a transform. The shell's `.workspace-body` slides +`.workspace-content-frame` when `showContextPanel` changes; the header slides +the drawer toggle, switcher, search and action groups when its leading +navigation (Back, drawer toggle) changes. + +1. **Previous positions.** `offsetLeft` values, which ignore transforms, are + recorded after every render in which the slide key or the record key + changed (the header's record key covers the other inputs that move its + controls: settings route, no playlists, parental lock, shortcuts) and + when the host or a target resizes (window resize, a context panel folding + or being dragged wider). A slide therefore starts where the target was + last laid out, and a target that has no recorded position or did not move + is left alone. +2. **The uncovered strip.** The frame is narrower beside the panel than + without it, so the side it moves away from would show the body colour + during the slide. While a slide runs the target carries + `layout-slide-active` and the uncovered widths as `--layout-slide-start` + and `--layout-slide-end`; the frame's stylesheet extends the content + surface over that strip with an `::after` below the scroller. +3. **Back fades in** over the same 180 ms, so the switcher still sliding away + from its slot does not draw over the arrow. +4. **Reduced motion.** Under `prefers-reduced-motion` there is no animation: + the jump (and its layout shift) remains. +5. **Compositing.** While it runs, each slid target is its own composited + layer (`ActiveTransformAnimation`): the frame at its width plus the + covered strip (1220×664 CSS pixels at 1280×720), the header controls a + few small ones. No synthesized clip mask appears, because no ancestor of + the frame clips with a radius ([content surface + corner](#content-surface-corner)), and the layers go away when the slide + ends: `perf:compositing` reads the same layer and mask counts on every + route as before the slides (#1895). +6. **Not covered.** A slide does not change what the route renders. Going + back from a portal to the dashboard still shows one frame with the new + dashboard below the old page before the old page leaves; that jump is a + routing issue, not a layout slide. + `WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is kept as a thin template-facing API and delegates ownership to component-scoped services: @@ -215,9 +266,10 @@ return logic (`backClicked`). When there is nowhere to go, the slot is empty rather than a disabled arrow. Sessions often start on a page that never navigates (an M3U playlist or live -TV), where a disabled arrow would stay for the whole session. The cost is one -shift of the switcher and search when Back first appears or leaves, which -happens only at the start of the history and together with a route change. +TV), where a disabled arrow would stay for the whole session. When Back first +appears or leaves, which happens only at the start of the history and together +with a route change, the switcher and search slide by the slot's width instead +of jumping (see [Layout slides](#layout-slides)). There is no Forward button: Stalker inline details are store state, not history entries, so Forward would skip them. @@ -263,7 +315,10 @@ Xtream and Stalker category panels preserve provider/server category order by default. The panel header exposes a sort menu next to category search with `Server sorting`, `A-Z`, and `Z-A`; when alphabetical sorting is active, synthetic "all categories" entries stay pinned before sorted provider -categories. +categories. Search and sort render disabled while the categories load (beside +the disabled Manage categories button), so the header row has its final width +under the skeleton instead of growing by two buttons when the list arrives; +they are hidden only once a load ended with no categories. On live sections the category panel can be folded away independently of the channels list; the folded panel is reachable as a popover from the channels diff --git a/libs/shared/database/src/lib/connection-migrations.spec.ts b/libs/shared/database/src/lib/connection-migrations.spec.ts index 977a13780..28d81c1f7 100644 --- a/libs/shared/database/src/lib/connection-migrations.spec.ts +++ b/libs/shared/database/src/lib/connection-migrations.spec.ts @@ -358,7 +358,12 @@ describe('content title FTS tokenizer upgrade', () => { expect.stringContaining("tokenize='trigram remove_diacritics 1'") ); expect(rebuild.run).toHaveBeenCalled(); - expect(marker.run).toHaveBeenCalled(); + // The rebuild also covers the plain index migration; without its + // record the next launch rebuilds the whole index again. + expect(marker.run.mock.calls).toEqual([ + ['migration:content-title-fts-remove-diacritics:v1'], + ['migration:content-title-fts-trigram:v1'], + ]); // One transaction, so a rejected CREATE rolls the drop back and the // working index survives. expect(transaction).toHaveBeenCalled(); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 03036b764..5f026114b 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -755,15 +755,19 @@ function upgradeContentTitleFtsTokenizer(sqliteDb: Database.Database): boolean { ) .run(); - sqliteDb - .prepare( - `INSERT INTO app_state (key, value, updated_at) - VALUES (?, 'done', datetime('now')) - ON CONFLICT(key) DO UPDATE SET - value = excluded.value, - updated_at = excluded.updated_at` - ) - .run(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY); + // The rebuild is also all that `ensureContentTitleFts` does, so + // its record is written too. Without it the next launch rebuilds + // the whole index again, on the main thread, on the first launch + // after a fresh install has imported its catalog. + const markDone = sqliteDb.prepare( + `INSERT INTO app_state (key, value, updated_at) + VALUES (?, 'done', datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at` + ); + markDone.run(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY); + markDone.run(CONTENT_TITLE_FTS_MIGRATION_KEY); }); executeMigration(); diff --git a/libs/shared/database/src/lib/testing/connection-upgrade.ts b/libs/shared/database/src/lib/testing/connection-upgrade.ts index faa162cee..2a6b071ff 100644 --- a/libs/shared/database/src/lib/testing/connection-upgrade.ts +++ b/libs/shared/database/src/lib/testing/connection-upgrade.ts @@ -71,6 +71,30 @@ function seed(sqlite: Database.Database) { } } +const titleIndexRebuilds: string[] = []; + +/** + * Every full rebuild of the title FTS index goes through `prepare`. A profile + * needs one; another on a later launch re-indexes the whole catalog on the + * main thread before the app can read anything. + */ +function countTitleIndexRebuilds(connection: Database.Database): void { + const prepare = connection.prepare; + connection.prepare = function countingPrepare( + this: Database.Database, + sql: string + ) { + if ( + /content_title_fts\(content_title_fts\)\s+VALUES\s+\('rebuild'\)/.test( + sql + ) + ) { + titleIndexRebuilds.push(sql); + } + return prepare.call(this, sql); + } as typeof connection.prepare; +} + /** * The performance capture counts one `exec` call as one SQL statement * (apps/electron-backend/src/app/workers/database-worker-sql-statement-count.ts), @@ -81,6 +105,7 @@ function seed(sqlite: Database.Database) { function requireSingleStatementExec(): string[] { const batches: string[] = []; setDatabaseConnectionObserver((connection) => { + countTitleIndexRebuilds(connection); const exec = connection.exec; connection.exec = function singleStatementExec(sql: string) { try { @@ -230,6 +255,11 @@ async function main() { [], 'Initialization must pass one SQL statement per exec call' ); + assert.equal( + titleIndexRebuilds.length, + 1, + 'The title index must be rebuilt once per profile, not on every launch' + ); process.stdout.write('upgrade verified'); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index fc4b6440b..d6ae3db4e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -3,12 +3,15 @@

{{ title() | translate }}

- @if (canSearchCategories()) { + + @if (showCategoryListActions()) { } - @if (canSearchCategories()) { + @if (showCategoryListActions()) {